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