| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330 |
- <?php
- declare(strict_types=1);
- // Pure-PHP ZIP writer. Ported from the PSA order system (includes/backup.php),
- // which builds the archive with pack() rather than requiring ext-zip, exec or
- // a temp copy of the whole archive in memory.
- //
- // One addition against the original: optional deflate compression (method 8).
- // The original stored everything uncompressed, which is fine for JPEGs but
- // wasteful for the SQL dumps this client can now produce.
- //
- // Limits (no Zip64): 4 GB per entry, 4 GB per archive, 65535 entries.
- function manageZipDosDateTime(int $timestamp): array
- {
- $parts = getdate($timestamp);
- $year = max(1980, (int) $parts["year"]);
- return [
- (($year - 1980) << 9) | ((int) $parts["mon"] << 5) | (int) $parts["mday"],
- ((int) $parts["hours"] << 11) |
- ((int) $parts["minutes"] << 5) |
- ((int) floor(((int) $parts["seconds"]) / 2)),
- ];
- }
- function manageZipValidateEntryName(string $name): void
- {
- $name = manageClientNormalizePath($name);
- if (
- $name === "" ||
- str_contains($name, "\0") ||
- str_starts_with($name, "/") ||
- preg_match('/^[A-Za-z]:\//', $name) === 1
- ) {
- throw new RuntimeException("Ungültiger Pfad im Backup: " . $name);
- }
- foreach (explode("/", $name) as $segment) {
- if ($segment === "" || $segment === "." || $segment === "..") {
- throw new RuntimeException("Ungültiger Pfad im Backup: " . $name);
- }
- }
- if (strlen($name) > 65535) {
- throw new RuntimeException("Pfad im Backup ist zu lang: " . $name);
- }
- }
- function manageZipWriteBytes($handle, string $data): void
- {
- $offset = 0;
- $length = strlen($data);
- while ($offset < $length) {
- $written = fwrite($handle, substr($data, $offset));
- if ($written === false || $written === 0) {
- throw new RuntimeException("Backup-ZIP konnte nicht geschrieben werden.");
- }
- $offset += $written;
- }
- }
- // Streams a stored (uncompressed) entry in 1 MiB chunks, so archive size is
- // never bounded by memory_limit.
- function manageZipCopyStored(string $file, $handle): void
- {
- $source = fopen($file, "rb");
- if ($source === false) {
- throw new RuntimeException("Datei konnte nicht gelesen werden: " . basename($file));
- }
- try {
- while (!feof($source)) {
- $chunk = fread($source, 1048576);
- if ($chunk === false) {
- throw new RuntimeException("Datei konnte nicht gelesen werden: " . basename($file));
- }
- if ($chunk !== "") {
- manageZipWriteBytes($handle, $chunk);
- }
- }
- } finally {
- fclose($source);
- }
- }
- // Deflates an entry with an incremental zlib stream, again without ever holding
- // the whole file in memory. Returns the compressed size.
- function manageZipCopyDeflated(string $file, $handle): int
- {
- $source = fopen($file, "rb");
- if ($source === false) {
- throw new RuntimeException("Datei konnte nicht gelesen werden: " . basename($file));
- }
- // Raw deflate (window -15) is what a ZIP entry with method 8 expects.
- $deflate = deflate_init(ZLIB_ENCODING_RAW, ["level" => 6]);
- if ($deflate === false) {
- fclose($source);
- throw new RuntimeException("Kompression konnte nicht initialisiert werden.");
- }
- $compressedSize = 0;
- try {
- while (!feof($source)) {
- $chunk = fread($source, 1048576);
- if ($chunk === false) {
- throw new RuntimeException("Datei konnte nicht gelesen werden: " . basename($file));
- }
- if ($chunk === "") {
- continue;
- }
- $encoded = deflate_add($deflate, $chunk, ZLIB_NO_FLUSH);
- if ($encoded === false) {
- throw new RuntimeException("Kompression fehlgeschlagen: " . basename($file));
- }
- if ($encoded !== "") {
- manageZipWriteBytes($handle, $encoded);
- $compressedSize += strlen($encoded);
- }
- }
- $encoded = deflate_add($deflate, "", ZLIB_FINISH);
- if ($encoded === false) {
- throw new RuntimeException("Kompression fehlgeschlagen: " . basename($file));
- }
- if ($encoded !== "") {
- manageZipWriteBytes($handle, $encoded);
- $compressedSize += strlen($encoded);
- }
- } finally {
- fclose($source);
- }
- return $compressedSize;
- }
- function manageZipCompressionAvailable(): bool
- {
- return MANAGE_BACKUP_COMPRESS === true &&
- function_exists("deflate_init") &&
- function_exists("deflate_add");
- }
- /**
- * Writes a ZIP archive.
- *
- * @param string $targetFile absolute path of the archive to create
- * @param array $files list of ["path" => absolute, "name" => entry name]
- *
- * @return array{file_count: int, source_bytes: int, archive_bytes: int, sha256: string}
- */
- function manageZipWrite(string $targetFile, array $files): array
- {
- if ($files === []) {
- throw new RuntimeException("Keine Dateien für das Backup gefunden.");
- }
- $handle = fopen($targetFile, "wb");
- if ($handle === false) {
- throw new RuntimeException("Backup-ZIP konnte nicht erstellt werden.");
- }
- $compress = manageZipCompressionAvailable();
- $centralDirectory = "";
- $fileCount = 0;
- $sourceBytes = 0;
- try {
- foreach ($files as $file) {
- $path = (string) ($file["path"] ?? "");
- $name = manageClientNormalizePath((string) ($file["name"] ?? ""));
- manageZipValidateEntryName($name);
- if (!is_file($path) || !is_readable($path)) {
- continue;
- }
- $size = filesize($path);
- if ($size === false) {
- throw new RuntimeException("Dateigröße konnte nicht ermittelt werden: " . $name);
- }
- if ($size > 0xffffffff) {
- throw new RuntimeException("Datei ist zu groß für dieses Backup-Format: " . $name);
- }
- $offset = ftell($handle);
- if ($offset === false || $offset > 0xffffffff) {
- throw new RuntimeException("Backup-ZIP ist zu groß für dieses Backup-Format.");
- }
- $crcHex = hash_file("crc32b", $path);
- if (!is_string($crcHex) || preg_match('/^[a-f0-9]{8}$/i', $crcHex) !== 1) {
- throw new RuntimeException("Prüfsumme konnte nicht berechnet werden: " . $name);
- }
- $crc = (int) hexdec($crcHex);
- [$dosDate, $dosTime] = manageZipDosDateTime((int) (filemtime($path) ?: time()));
- $nameLength = strlen($name);
- // An empty file must stay stored: deflate would emit a 2-byte body
- // for zero input, which some readers reject.
- $useDeflate = $compress && $size > 0;
- $method = $useDeflate ? 8 : 0;
- // The local header needs the compressed size up front, which is not
- // known before compressing. The header is therefore written with a
- // placeholder and patched after the body, exactly like a two-pass
- // writer; seeking is safe because the target is a real file.
- manageZipWriteBytes(
- $handle,
- pack(
- "VvvvvvVVVvv",
- 0x04034b50,
- $useDeflate ? 20 : 10,
- 0,
- $method,
- $dosTime,
- $dosDate,
- $crc,
- 0,
- $size,
- $nameLength,
- 0,
- ) . $name,
- );
- if ($useDeflate) {
- $compressedSize = manageZipCopyDeflated($path, $handle);
- } else {
- manageZipCopyStored($path, $handle);
- $compressedSize = $size;
- }
- if ($compressedSize > 0xffffffff) {
- throw new RuntimeException("Datei ist zu groß für dieses Backup-Format: " . $name);
- }
- if ($useDeflate) {
- $afterEntry = ftell($handle);
- if ($afterEntry === false) {
- throw new RuntimeException("Backup-ZIP konnte nicht geschrieben werden.");
- }
- // Compressed size sits 18 bytes into the local file header.
- if (fseek($handle, $offset + 18) !== 0) {
- throw new RuntimeException("Backup-ZIP konnte nicht aktualisiert werden.");
- }
- manageZipWriteBytes($handle, pack("V", $compressedSize));
- if (fseek($handle, $afterEntry) !== 0) {
- throw new RuntimeException("Backup-ZIP konnte nicht aktualisiert werden.");
- }
- }
- $centralDirectory .=
- pack(
- "VvvvvvvVVVvvvvvVV",
- 0x02014b50,
- 0x031e,
- $useDeflate ? 20 : 10,
- 0,
- $method,
- $dosTime,
- $dosDate,
- $crc,
- $compressedSize,
- $size,
- $nameLength,
- 0,
- 0,
- 0,
- 0,
- 0,
- $offset,
- ) .
- $name;
- $fileCount++;
- $sourceBytes += $size;
- }
- if ($fileCount < 1) {
- throw new RuntimeException("Keine lesbaren Dateien für das Backup gefunden.");
- }
- if ($fileCount > 65535) {
- throw new RuntimeException("Zu viele Dateien für dieses Backup-Format.");
- }
- $centralOffset = ftell($handle);
- $centralSize = strlen($centralDirectory);
- if (
- $centralOffset === false ||
- $centralOffset > 0xffffffff ||
- $centralSize > 0xffffffff
- ) {
- throw new RuntimeException("Backup-ZIP ist zu groß für dieses Backup-Format.");
- }
- manageZipWriteBytes($handle, $centralDirectory);
- manageZipWriteBytes(
- $handle,
- pack(
- "VvvvvVVv",
- 0x06054b50,
- 0,
- 0,
- $fileCount,
- $fileCount,
- $centralSize,
- $centralOffset,
- 0,
- ),
- );
- } catch (Throwable $exception) {
- fclose($handle);
- @unlink($targetFile);
- throw $exception;
- }
- fclose($handle);
- @chmod($targetFile, 0660);
- return [
- "file_count" => $fileCount,
- "source_bytes" => $sourceBytes,
- "archive_bytes" => (int) (filesize($targetFile) ?: 0),
- "sha256" => hash_file("sha256", $targetFile) ?: "",
- ];
- }
|