Skip to content

Perfect-Zip 4.0.0

Latest

Choose a tag to compare

@taplin taplin released this 23 Oct 13:18
004ce7e

Perfect-Zip 4.0.0 is the first tagged release of the Swift 6 rewrite. It replaces 3.0.3 (2018). The bundled C library (a 2010-era MiniZip 1.1 fork) is gone: the ZIP format is now about 1,500 lines of Swift, with compression from the system zlib. Extraction is built for untrusted archives. It fixes Zip Slip, never writes through symlinks, validates the archive structure and has zip-bomb limits.

.package(url: "https://github.com/PerfectlySoft/Perfect-Zip.git", from: "4.0.0"),

Requirements

  • Swift 6.2 or later (swift-tools-version: 6.2, Swift 6 language mode).
  • macOS 12 or later, or Linux with the zlib headers (zlib1g-dev; the official swift images include it). CI covers Swift 6.2 and 6.4 on Ubuntu 24.04 and Xcode 26.0.1 and 26.6 on macOS, in debug and release, with warnings as errors.
  • No package dependencies. zlib is reached through a system-library module map, not vendored.

Highlights

  • Swift-native ZIP reader and writer. The writer covers local headers, the central directory, Zip64 (sizes, offsets, end records, the 0xFFFF entry count), data descriptors with and without a signature, UTF-8 and CP437 names, and Unix modes and times. Deflate and inflate use the system zlib's raw streams. Data streams through fixed 64 KiB buffers, so a 5 GB file is never loaded into memory.
  • Zip Slip fixed. Every entry name is checked before anything is written; unsafe names fail the whole extraction.
  • Safe with symlinks. Extraction never follows a symlink in the destination (meeting one fails the extraction) and never creates one. Zipping skips symlinks found inside a directory tree.
  • Zip-bomb limits with ZipLimits, configurable with Zip(limits:). The defaults are 16 GiB in total, and at most 200:1 (declared uncompressed size divided by compressed size) for every entry of 1 MiB or more, and for all extracted entries together once they add up to 1 MiB or more. Skipped __MACOSX/ entries are left out of that sum.
  • Reads ZipCrypto archives (zip -P), checking the password before writing anything.
  • Permissions and times are recorded when zipping and restored when unzipping.
  • Works on Linux (Swift 6.2 and 6.4, arm64 and x86_64).

Breaking and behaviour changes

  • The minizip module is gone. 3.x exported the bundled C library as part of the PerfectZip product (targets: ["PerfectZip", "minizip"]), so import minizip worked. It no longer exists. Upgrade: only if you called minizip directly; use another library for that code.
  • No more encrypted archives from zipFiles. zipFiles(paths:zipFilePath:overwrite:password:) is deprecated; with a non-empty password it returns the new ZipStatus.ZipEncryptionUnsupported and writes nothing. A new overload zipFiles(paths:zipFilePath:overwrite:) has no password. Upgrade: drop the password: argument. Passwords never worked in 3.x anyway (see Fixes).
  • New ZipStatus cases: UnzipUnsafePath, UnzipLimitExceeded, UnzipIncorrectPassword and ZipEncryptionUnsupported. ZipStatus is now CustomStringConvertible. Upgrade: an exhaustive switch needs the new cases or a default:.
  • Unsafe entry names fail the whole extraction with UnzipUnsafePath, and nothing is written. That covers absolute paths, Windows drive paths, any .. component (even one that would stay inside), NUL, invalid UTF-8 when the UTF-8 flag is set, names more than 256 components deep, and anything that resolves outside the destination. Upgrade: none for well-formed archives.
  • Symlinks:
    • Extraction fails with UnzipUnsafePath instead of following a symlink that already exists inside the destination, at any level, or replacing one.
    • Symlink entries in an archive are extracted as regular files holding the link text, as in 3.x.
    • When zipping, symlinks found while walking a directory are skipped. 3.x followed them, including loops and links pointing outside the tree. A path passed directly in paths is still followed.
    • The destination itself may be a symlink; it is resolved first.
  • Permissions and times.
    • zipFiles records each entry's Unix mode and modification time: a DOS time plus a UTC "UT" extra field. 3.x wrote mode 0 and an all-zero date.
    • unzipFile restores the permission bits of regular files from archives made on Unix or macOS, masked by the umask as bsdtar does. Under umask 022, a recorded 0o777 becomes 0o755, so an archive can't create world-writable files. This is the effective umask of the entry's directory, so a default ACL takes its place where supported; if it can't be read, 0o022 is used. It is read lazily, the first time a recorded mode is applied, so a read-only destination that needs no writes still extracts. Setuid, setgid and sticky bits are never restored.
    • unzipFile restores modification times, preferring the UTC field.
    • Directories are created with mode 0o755 (less the umask) and get no times. Restoring a directory's mode could lock extraction out of it, and its time changes again as entries are written.
  • Stricter, fail-closed extraction.
    • Damaged archives now return UnzipFail where 3.x often reported success with files missing.
    • The archive's structure, names, limits and password are all checked before anything is written. Clashes with the filesystem (a file where the destination has a directory, case-only name differences on case-insensitive volumes) and I/O errors are only found during extraction, and fail closed there.
    • Also refused: overlapping entries, local headers that disagree with the central directory, multi-disk archives, data before the archive (self-extractors), AES and other unsupported methods, and entries that produce more data than they declare.
    • With overwrite: false, an archive that names the same file twice returns ZipCannotOverwrite; 3.x skipped the duplicate silently.
    • __MACOSX/ entries are skipped, files as well as directories.
  • Zip-bomb limits are on by default.
    • These archives return UnzipLimitExceeded:
      • archives that would expand past 16 GiB in total;
      • archives with any entry of 1 MiB or more whose declared size is more than 200 times its compressed size;
      • archives whose extracted entries together are 1 MiB or more and more than 200:1 overall (skipped __MACOSX/ entries are not counted).
    • The whole-archive check stops a bomb split into many small entries. Large or very compressible archives, such as a sparse disk image, can hit these limits.
    • Upgrade: pass Zip(limits: ZipLimits(...)), or .unlimited for trusted input.
  • Different archives from zipFiles.
    • Archives now include directory entries, so empty directories survive.
    • They use Zip64 where needed.
    • They are written to a temporary file and renamed into place.
    • Two inputs that would produce the same entry name return ZipFail.
    • The archive is never added to itself.
  • overwrite: true replaces files instead of truncating them. 3.x truncated in place. Now:
    • The entry is written to a temporary file in the same directory and renamed over the original only once it is complete and its CRC checks out.
    • A hard link in the destination is broken rather than written through.
    • A failed replacement keeps the original.
    • Read-only files are replaced too.
    • A replaced file keeps its permission bits when the archive records none (Windows-made archives).
    • If the process dies mid-replace, the original is untouched and a 0o600 .perfectzip-*.tmp file may be left next to it. Likewise, zipFiles may leave a .<name>.<UUID>.tmp next to zipFilePath.
    • Ownership, ACLs and extended attributes of the old file are not kept.
  • Archives with no entries work. 3.x couldn't open them.
  • ProcessedFilePath is deprecated and unused.

Fixes

Against 3.0.3 and the pre-release main (a2e1a0a):

  • Zip Slip (path traversal): entries named ../../x, ..\..\x or a/../../x were written outside the destination, and unzipFile returned ZipSuccess. Reproduced on main with Python-made archives. An archive entry link/x was also written through a symlink link already inside the destination.
  • Passwords never worked:
    • unzipFile ignored its password argument.
    • zipFiles passed a CRC of 0 to the encryption header, so the check byte was wrong. Info-ZIP unzip -P reported "incorrect password" and Python's zipfile "Bad password" for every encrypted archive 3.x wrote.
  • Memory bug in the bundled C code: fuzzing truncated and bit-flipped archives under AddressSanitizer hit an overlapping memcpy in the fork's unzReadCurrentFile (unzip.c:1361) 7 times. It is gone with the C code.
  • Errors reported as success: directory-creation failures, short writes, read errors, a damaged central directory and minizip's return codes were ignored. For example, a/b behind a file a was dropped and ZipSuccess returned.
  • Files over 4 GB: for a 5 GiB sparse file, main reported ZipSuccess, but its own unzipFile returned UnzipFail on the result, and Info-ZIP unzip -t reported "invalid compressed data to inflate". The same file now round-trips with matching CRC, and unzip -t and Python's testzip() accept the archive.
  • Zip side:
    • Passing dir/sub/.. produced entries named ../....
    • Symlink loops recursed without end.
    • A failure could leave a truncated archive in place of the old one.
  • Found in review before release:
    • Hard links: overwrite: true truncated an existing file in place, which wrote through a hard link to a file outside the destination. A first fix (unlink, then recreate) lost the original if extraction then failed, and turned a 0o600 file into 0o644. The final fix writes to a temporary file and renames it into place.
    • Split zip bombs: 64 entries of exactly 1 MiB of zeros (a 72 KB archive) expanded to 64 MiB, because only entries over 1 MiB were ratio-checked. Fixed by the inclusive floor and the whole-archive ratio.
    • Deep paths: each entry re-walked its directories from the root, so nested directory entries took quadratic time (depth 1,000: 37 s; depth 2,000: 280 s on macOS). The 256-component cap fixed it; depth 255 now takes 0.15 s. A directory-descriptor cache tried in between was dropped: it kept writing into a directory even after another process had renamed it out of the destination (20,000 of 20,000 files landed outside, against about 50 in flight without it). Each entry walks from the root again, so in that race only the files already in the moved directory end up outside.
  • CI: new GitHub Actions workflow: Linux Swift 6.2/6.4 and macOS Xcode 26.0.1/26.6, debug and release, warnings as errors, contents: read, 30-minute timeouts. Linux installs zlib1g-dev and the interop tools.
  • README: install line, requirements, a security section, status codes and a 3.x migration section. README.zh_CN.md was removed; it described the Swift 3 API.

Verified:

  • Debug and release builds with warnings as errors, and the tests, on Xcode 27 (Swift 6.4) and Xcode 26.3 (Swift 6.2.4).
  • The same on Linux: swift:6.2-noble and swift:6.4-noble on arm64, and swift:6.4-noble on amd64.
  • AddressSanitizer and ThreadSanitizer test runs on macOS.
  • 30 repeated amd64 test runs under heavy CPU oversubscription (16 busy loops on 4 cores), with no failures.
  • Interop tests both ways with Info-ZIP zip/unzip, ditto, bsdtar and Python's zipfile (stored, deflated, forced Zip64, streamed with data descriptors), and reading zip -P archives.
  • The opt-in 5 GiB Zip64 test.
  • Fuzzing: 16,025 mutated archives under ASan on macOS, and libFuzzer with ASan on Linux (swift:6.4-noble, 4 workers × 15 minutes, about 5.8 million inputs, seeded with archives from each tool above). It was rerun for 5 minutes after the review fixes (about 756,000 more inputs, with split-bomb and deep-path seeds). No crashes, hangs, leaks or out-of-memory errors.
  • The one-off amd64 crash during pre-release testing was the test harness, not the library. It read tool output with Pipe.readDataToEndOfFile(), which in corelibs Foundation traps (try!) when read(2) returns EINTR. The review captured the trap at FileHandle.swift:699, and a standalone program reproduces it every time on Linux by sending signals. The harness now captures output through a file, and Linux CI disables Swift's backtracer, which had hung for 50 minutes after the crash.

Known limitations

  • Not supported: AES-encrypted entries, bzip2/LZMA/zstd and other methods, multi-disk archives, and archives with data in front (self-extractors). All return UnzipFail.
  • ZipCrypto offers no real security. A wrong password passes the one-byte check about 1 time in 256 and then fails the CRC check (UnzipFail) for that entry.
  • The central directory is read into memory whole. Its size is bounded by the archive's.
  • Failures mid-extraction: some failures only show up after extraction starts: damaged deflate data, a full disk, a symlink in the destination, a file where the destination has a directory, or names the filesystem folds together (case-only differences on APFS). These fail closed with a partial result. Entries already extracted stay, and a file whose replacement failed is kept, but files already replaced earlier in the run are not restored.
  • Concurrent changes: the guarantees are against hostile archives, not against another process changing the destination during extraction. If a directory is moved out mid-run, the files already in it (and the one being written) go with it. Later entries are placed from the destination root again, and a swapped-in symlink is refused.
  • Duplicate names: with overwrite: false, names are compared as bytes, so NFC and NFD spellings count as different names (as on Linux filesystems). On APFS they collide when created and fail closed there.
  • The ratio limits judge by declared sizes. Each entry must then produce exactly its declared size. Legitimately very compressible archives (sparse images, zero-filled data) need a higher maxCompressionRatio. Together the checks bound what is written to 200 times the compressed size of the entries that are written (and 16 GiB). Within that, small highly compressed entries can still hide behind a large incompressible entry that is also extracted. Padding in skipped __MACOSX/ entries, which are never written, doesn't count.
  • Name decoding: Info-ZIP's Unicode Path extra field (0x7075) is ignored. Names without the UTF-8 flag are taken as UTF-8 if valid, otherwise CP437.
  • Not restored: symlinks (they are never created), directory permissions and times (see above), and extended attributes or resource forks. On macOS, __MACOSX AppleDouble data is skipped.
  • Synchronous API: both calls block the calling thread.

Upgrade notes

  • From 3.x: see Breaking changes. Most code only needs password: removed from zipFiles calls and new cases handled in any exhaustive switch on ZipStatus.
  • From the pre-release main: the same, plus the stricter extraction and default limits.

Pull requests

  • #5: Swift 6 resurrection (2026-09-01).
  • #6: 4.0.0 (minizip replaced by Swift over the system zlib, Zip Slip fix, hardened extraction, CI).

Legacy

The pre-Swift-6 code (3.0.3) is on the legacy branch. Stay on 3.x if you can't move to Swift 6.2.