zip.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. <?php
  2. declare(strict_types=1);
  3. // Pure-PHP ZIP writer. Builds the archive with pack() rather than requiring
  4. // ext-zip, exec or a temp copy of the whole archive in memory.
  5. //
  6. // Deflate compression (method 8) is optional: storing everything uncompressed
  7. // is fine for JPEGs but wasteful for the SQL dumps this client can produce.
  8. //
  9. // Limits (no Zip64): 4 GB per entry, 4 GB per archive, 65535 entries.
  10. function manageZipDosDateTime(int $timestamp): array
  11. {
  12. $parts = getdate($timestamp);
  13. $year = max(1980, (int) $parts["year"]);
  14. return [
  15. (($year - 1980) << 9) | ((int) $parts["mon"] << 5) | (int) $parts["mday"],
  16. ((int) $parts["hours"] << 11) |
  17. ((int) $parts["minutes"] << 5) |
  18. ((int) floor(((int) $parts["seconds"]) / 2)),
  19. ];
  20. }
  21. function manageZipValidateEntryName(string $name): void
  22. {
  23. $name = manageClientNormalizePath($name);
  24. if (
  25. $name === "" ||
  26. str_contains($name, "\0") ||
  27. str_starts_with($name, "/") ||
  28. preg_match('/^[A-Za-z]:\//', $name) === 1
  29. ) {
  30. throw new RuntimeException("Ungültiger Pfad im Backup: " . $name);
  31. }
  32. foreach (explode("/", $name) as $segment) {
  33. if ($segment === "" || $segment === "." || $segment === "..") {
  34. throw new RuntimeException("Ungültiger Pfad im Backup: " . $name);
  35. }
  36. }
  37. if (strlen($name) > 65535) {
  38. throw new RuntimeException("Pfad im Backup ist zu lang: " . $name);
  39. }
  40. }
  41. function manageZipWriteBytes($handle, string $data): void
  42. {
  43. $offset = 0;
  44. $length = strlen($data);
  45. while ($offset < $length) {
  46. $written = fwrite($handle, substr($data, $offset));
  47. if ($written === false || $written === 0) {
  48. throw new RuntimeException("Backup-ZIP konnte nicht geschrieben werden.");
  49. }
  50. $offset += $written;
  51. }
  52. }
  53. // Streams a stored (uncompressed) entry in 1 MiB chunks, so archive size is
  54. // never bounded by memory_limit.
  55. function manageZipCopyStored(string $file, $handle): void
  56. {
  57. $source = fopen($file, "rb");
  58. if ($source === false) {
  59. throw new RuntimeException("Datei konnte nicht gelesen werden: " . basename($file));
  60. }
  61. try {
  62. while (!feof($source)) {
  63. $chunk = fread($source, 1048576);
  64. if ($chunk === false) {
  65. throw new RuntimeException("Datei konnte nicht gelesen werden: " . basename($file));
  66. }
  67. if ($chunk !== "") {
  68. manageZipWriteBytes($handle, $chunk);
  69. }
  70. }
  71. } finally {
  72. fclose($source);
  73. }
  74. }
  75. // Deflates an entry with an incremental zlib stream, again without ever holding
  76. // the whole file in memory. Returns the compressed size.
  77. function manageZipCopyDeflated(string $file, $handle): int
  78. {
  79. $source = fopen($file, "rb");
  80. if ($source === false) {
  81. throw new RuntimeException("Datei konnte nicht gelesen werden: " . basename($file));
  82. }
  83. // Raw deflate (window -15) is what a ZIP entry with method 8 expects.
  84. $deflate = deflate_init(ZLIB_ENCODING_RAW, ["level" => 6]);
  85. if ($deflate === false) {
  86. fclose($source);
  87. throw new RuntimeException("Kompression konnte nicht initialisiert werden.");
  88. }
  89. $compressedSize = 0;
  90. try {
  91. while (!feof($source)) {
  92. $chunk = fread($source, 1048576);
  93. if ($chunk === false) {
  94. throw new RuntimeException("Datei konnte nicht gelesen werden: " . basename($file));
  95. }
  96. if ($chunk === "") {
  97. continue;
  98. }
  99. $encoded = deflate_add($deflate, $chunk, ZLIB_NO_FLUSH);
  100. if ($encoded === false) {
  101. throw new RuntimeException("Kompression fehlgeschlagen: " . basename($file));
  102. }
  103. if ($encoded !== "") {
  104. manageZipWriteBytes($handle, $encoded);
  105. $compressedSize += strlen($encoded);
  106. }
  107. }
  108. $encoded = deflate_add($deflate, "", ZLIB_FINISH);
  109. if ($encoded === false) {
  110. throw new RuntimeException("Kompression fehlgeschlagen: " . basename($file));
  111. }
  112. if ($encoded !== "") {
  113. manageZipWriteBytes($handle, $encoded);
  114. $compressedSize += strlen($encoded);
  115. }
  116. } finally {
  117. fclose($source);
  118. }
  119. return $compressedSize;
  120. }
  121. function manageZipCompressionAvailable(): bool
  122. {
  123. return MANAGE_BACKUP_COMPRESS === true &&
  124. function_exists("deflate_init") &&
  125. function_exists("deflate_add");
  126. }
  127. /**
  128. * Writes a ZIP archive.
  129. *
  130. * @param string $targetFile absolute path of the archive to create
  131. * @param array $files list of ["path" => absolute, "name" => entry name]
  132. *
  133. * @return array{file_count: int, source_bytes: int, archive_bytes: int, sha256: string}
  134. */
  135. function manageZipWrite(string $targetFile, array $files): array
  136. {
  137. if ($files === []) {
  138. throw new RuntimeException("Keine Dateien für das Backup gefunden.");
  139. }
  140. $handle = fopen($targetFile, "wb");
  141. if ($handle === false) {
  142. throw new RuntimeException("Backup-ZIP konnte nicht erstellt werden.");
  143. }
  144. $compress = manageZipCompressionAvailable();
  145. $centralDirectory = "";
  146. $fileCount = 0;
  147. $sourceBytes = 0;
  148. try {
  149. foreach ($files as $file) {
  150. $path = (string) ($file["path"] ?? "");
  151. $name = manageClientNormalizePath((string) ($file["name"] ?? ""));
  152. manageZipValidateEntryName($name);
  153. if (!is_file($path) || !is_readable($path)) {
  154. continue;
  155. }
  156. $size = filesize($path);
  157. if ($size === false) {
  158. throw new RuntimeException("Dateigröße konnte nicht ermittelt werden: " . $name);
  159. }
  160. if ($size > 0xffffffff) {
  161. throw new RuntimeException("Datei ist zu groß für dieses Backup-Format: " . $name);
  162. }
  163. $offset = ftell($handle);
  164. if ($offset === false || $offset > 0xffffffff) {
  165. throw new RuntimeException("Backup-ZIP ist zu groß für dieses Backup-Format.");
  166. }
  167. $crcHex = hash_file("crc32b", $path);
  168. if (!is_string($crcHex) || preg_match('/^[a-f0-9]{8}$/i', $crcHex) !== 1) {
  169. throw new RuntimeException("Prüfsumme konnte nicht berechnet werden: " . $name);
  170. }
  171. $crc = (int) hexdec($crcHex);
  172. [$dosDate, $dosTime] = manageZipDosDateTime((int) (filemtime($path) ?: time()));
  173. $nameLength = strlen($name);
  174. // An empty file must stay stored: deflate would emit a 2-byte body
  175. // for zero input, which some readers reject.
  176. $useDeflate = $compress && $size > 0;
  177. $method = $useDeflate ? 8 : 0;
  178. // The local header needs the compressed size up front, which is not
  179. // known before compressing. The header is therefore written with a
  180. // placeholder and patched after the body, exactly like a two-pass
  181. // writer; seeking is safe because the target is a real file.
  182. manageZipWriteBytes(
  183. $handle,
  184. pack(
  185. "VvvvvvVVVvv",
  186. 0x04034b50,
  187. $useDeflate ? 20 : 10,
  188. 0,
  189. $method,
  190. $dosTime,
  191. $dosDate,
  192. $crc,
  193. 0,
  194. $size,
  195. $nameLength,
  196. 0,
  197. ) . $name,
  198. );
  199. if ($useDeflate) {
  200. $compressedSize = manageZipCopyDeflated($path, $handle);
  201. } else {
  202. manageZipCopyStored($path, $handle);
  203. $compressedSize = $size;
  204. }
  205. if ($compressedSize > 0xffffffff) {
  206. throw new RuntimeException("Datei ist zu groß für dieses Backup-Format: " . $name);
  207. }
  208. if ($useDeflate) {
  209. $afterEntry = ftell($handle);
  210. if ($afterEntry === false) {
  211. throw new RuntimeException("Backup-ZIP konnte nicht geschrieben werden.");
  212. }
  213. // Compressed size sits 18 bytes into the local file header.
  214. if (fseek($handle, $offset + 18) !== 0) {
  215. throw new RuntimeException("Backup-ZIP konnte nicht aktualisiert werden.");
  216. }
  217. manageZipWriteBytes($handle, pack("V", $compressedSize));
  218. if (fseek($handle, $afterEntry) !== 0) {
  219. throw new RuntimeException("Backup-ZIP konnte nicht aktualisiert werden.");
  220. }
  221. }
  222. $centralDirectory .=
  223. pack(
  224. "VvvvvvvVVVvvvvvVV",
  225. 0x02014b50,
  226. 0x031e,
  227. $useDeflate ? 20 : 10,
  228. 0,
  229. $method,
  230. $dosTime,
  231. $dosDate,
  232. $crc,
  233. $compressedSize,
  234. $size,
  235. $nameLength,
  236. 0,
  237. 0,
  238. 0,
  239. 0,
  240. 0,
  241. $offset,
  242. ) .
  243. $name;
  244. $fileCount++;
  245. $sourceBytes += $size;
  246. }
  247. if ($fileCount < 1) {
  248. throw new RuntimeException("Keine lesbaren Dateien für das Backup gefunden.");
  249. }
  250. if ($fileCount > 65535) {
  251. throw new RuntimeException("Zu viele Dateien für dieses Backup-Format.");
  252. }
  253. $centralOffset = ftell($handle);
  254. $centralSize = strlen($centralDirectory);
  255. if (
  256. $centralOffset === false ||
  257. $centralOffset > 0xffffffff ||
  258. $centralSize > 0xffffffff
  259. ) {
  260. throw new RuntimeException("Backup-ZIP ist zu groß für dieses Backup-Format.");
  261. }
  262. manageZipWriteBytes($handle, $centralDirectory);
  263. manageZipWriteBytes(
  264. $handle,
  265. pack(
  266. "VvvvvVVv",
  267. 0x06054b50,
  268. 0,
  269. 0,
  270. $fileCount,
  271. $fileCount,
  272. $centralSize,
  273. $centralOffset,
  274. 0,
  275. ),
  276. );
  277. } catch (Throwable $exception) {
  278. fclose($handle);
  279. @unlink($targetFile);
  280. throw $exception;
  281. }
  282. fclose($handle);
  283. @chmod($targetFile, 0660);
  284. return [
  285. "file_count" => $fileCount,
  286. "source_bytes" => $sourceBytes,
  287. "archive_bytes" => (int) (filesize($targetFile) ?: 0),
  288. "sha256" => hash_file("sha256", $targetFile) ?: "",
  289. ];
  290. }