4 GB entry must not require a different one. Central directory entries * are built at the end, when the real values are known, so those use ZIP64 only * where a field actually overflows. * * No data descriptors are used (the patch makes them unnecessary), which keeps * the output readable by the widest range of tools. */ declare(strict_types=1); /** Fixed part of a local file header, before filename and extra field. */ const ZIP_LOCAL_FIXED = 30; /** ZIP64 extra field in a local header: id + size + 8-byte sizes. */ const ZIP_ZIP64_EXTRA = 20; /** Value meaning "look in the ZIP64 extra field for the real number". */ const ZIP_MAX32 = 0xFFFFFFFF; /** Convert a Unix timestamp to the DOS date/time pair ZIP stores. */ function zip_dos_time(int $timestamp): array { $t = getdate($timestamp); // DOS time cannot represent anything before 1980; clamp rather than wrap. if ($t['year'] < 1980) { return [0, 0x21]; // 1980-01-01 00:00:00 } $time = ($t['hours'] << 11) | ($t['minutes'] << 5) | (int)($t['seconds'] / 2); $date = (($t['year'] - 1980) << 9) | ($t['mon'] << 5) | $t['mday']; return [$time, $date]; } /** * Total length of the local header for $name, i.e. how far the file's bytes sit * from the start of the entry. Callers need this to track archive offsets. */ function zip_local_header_size(string $name): int { return ZIP_LOCAL_FIXED + strlen($name) + ZIP_ZIP64_EXTRA; } /** * Local file header with placeholder CRC and sizes, to be patched once the * file's bytes have been written (zip_patch_local_header). * * Flag bit 11 marks the filename as UTF-8 so "Straße.jpg" survives; without it * readers fall back to CP437 and mangle anything non-ASCII. */ function zip_local_header(string $name, int $mtime): string { [$time, $date] = zip_dos_time($mtime); return pack('V', 0x04034b50) // local file header signature . pack('v', 45) // version needed to extract: 4.5 (ZIP64) . pack('v', 0x0800) // flags: UTF-8 filename . pack('v', 0) // method: stored . pack('v', $time) . pack('v', $date) . pack('V', 0) // CRC-32 — patched later . pack('V', ZIP_MAX32) // compressed size → ZIP64 extra . pack('V', ZIP_MAX32) // uncompressed size → ZIP64 extra . pack('v', strlen($name)) . pack('v', ZIP_ZIP64_EXTRA) . $name . pack('v', 0x0001) // ZIP64 extended information extra field . pack('v', 16) // ... holding two 8-byte sizes . pack('P', 0) // uncompressed size — patched later . pack('P', 0); // compressed size — patched later } /** * Fill in the CRC and size a local header was written without. * * $entryOffset is the position of the header's signature within $fh. The handle * is left at end-of-file so the caller can carry on appending. * * @param resource $fh */ function zip_patch_local_header($fh, int $entryOffset, string $name, int $crc, int $size): void { // CRC-32 sits 14 bytes into the fixed header; the two ZIP64 sizes sit 4 // bytes into the extra field, which follows the filename. fseek($fh, $entryOffset + 14); fwrite($fh, pack('V', $crc)); fseek($fh, $entryOffset + ZIP_LOCAL_FIXED + strlen($name) + 4); fwrite($fh, pack('P', $size) . pack('P', $size)); fseek($fh, 0, SEEK_END); } /** * One central directory entry. * * $entry: ['name' => string, 'crc' => int, 'size' => int, 'offset' => int, * 'mtime' => int] — offset being the entry's local header position. * * ZIP64 fields appear only when a value genuinely overflows 32 bits, and the * spec requires them in a fixed order (uncompressed, compressed, offset), each * present only if its fixed-record counterpart was set to 0xFFFFFFFF. */ function zip_central_entry(array $entry): string { [$time, $date] = zip_dos_time($entry['mtime']); $name = $entry['name']; $size = $entry['size']; $offset = $entry['offset']; $bigSize = $size >= ZIP_MAX32; $bigOffset = $offset >= ZIP_MAX32; $extra = ''; if ($bigSize) { $extra .= pack('P', $size) . pack('P', $size); } if ($bigOffset) { $extra .= pack('P', $offset); } if ($extra !== '') { $extra = pack('v', 0x0001) . pack('v', strlen($extra)) . $extra; } return pack('V', 0x02014b50) // central file header signature . pack('v', 45) // version made by: 4.5, MS-DOS . pack('v', 45) // version needed to extract . pack('v', 0x0800) // flags: UTF-8 filename . pack('v', 0) // method: stored . pack('v', $time) . pack('v', $date) . pack('V', $entry['crc']) . pack('V', $bigSize ? ZIP_MAX32 : $size) . pack('V', $bigSize ? ZIP_MAX32 : $size) . pack('v', strlen($name)) . pack('v', strlen($extra)) . pack('v', 0) // file comment length . pack('v', 0) // disk number start . pack('v', 0) // internal file attributes . pack('V', 0) // external file attributes . pack('V', $bigOffset ? ZIP_MAX32 : $offset) . $name . $extra; } /** * The archive trailer: ZIP64 end-of-central-directory record, its locator, and * the classic EOCD. * * The ZIP64 pair is always emitted. A reader that predates ZIP64 skips straight * to the classic EOCD at the end of the file and works as long as nothing * overflows; one that supports ZIP64 finds the locator immediately before it and * gets the wide values. Emitting both is what lets a 300 MB archive and a 40 GB * archive share a single code path. */ function zip_end_of_central_directory(int $count, int $cdSize, int $cdOffset): string { $zip64Eocd = pack('V', 0x06064b50) // ZIP64 EOCD signature . pack('P', 44) // size of the rest of this record . pack('v', 45) // version made by . pack('v', 45) // version needed . pack('V', 0) // this disk . pack('V', 0) // disk with start of CD . pack('P', $count) // entries on this disk . pack('P', $count) // entries total . pack('P', $cdSize) . pack('P', $cdOffset); $locator = pack('V', 0x07064b50) // ZIP64 EOCD locator signature . pack('V', 0) // disk with the ZIP64 EOCD . pack('P', $cdOffset + $cdSize) // its offset: right after the CD . pack('V', 1); // total number of disks $eocd = pack('V', 0x06054b50) // EOCD signature . pack('v', 0) // this disk . pack('v', 0) // disk with start of CD . pack('v', min($count, 0xFFFF)) . pack('v', min($count, 0xFFFF)) . pack('V', min($cdSize, ZIP_MAX32)) . pack('V', min($cdOffset, ZIP_MAX32)) . pack('v', 0); // archive comment length return $zip64Eocd . $locator . $eocd; } /** * Make $name unique within the archive, remembering what has been used. * * Gallery images keep their original filename while their S3 key gets a random * token, so two cameras both producing DSC_0001.jpg is entirely normal — and a * ZIP with duplicate names silently loses files on extraction. */ function zip_dedupe_name(string $name, array &$seen): string { $key = strtolower($name); if (!isset($seen[$key])) { $seen[$key] = 1; return $name; } $ext = pathinfo($name, PATHINFO_EXTENSION); $base = $ext !== '' ? substr($name, 0, -(strlen($ext) + 1)) : $name; do { $candidate = $base . ' (' . (++$seen[$key]) . ')' . ($ext !== '' ? '.' . $ext : ''); } while (isset($seen[strtolower($candidate)])); $seen[strtolower($candidate)] = 1; return $candidate; }