manage-zip.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. <?php
  2. // Pure-PHP ZIP writer for backups. Builds the archive with pack() rather than
  3. // requiring ext-zip, exec or a temp copy of the whole archive in memory, and
  4. // reports file count, source size and SHA-256 in the same pass — the backup
  5. // index and the upload metadata both need those numbers.
  6. //
  7. // Deflate compression (method 8) is optional: storing everything uncompressed
  8. // is fine for the product JPEGs, wasteful for the JSON data files.
  9. //
  10. // Limits (no Zip64): 4 GB per entry, 4 GB per archive, 65535 entries.
  11. declare(strict_types=1);
  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. }