zip.php 11 KB

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