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) ?: "", ]; }