Repository navigation
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 withZip(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
minizipmodule is gone. 3.x exported the bundled C library as part of thePerfectZipproduct (targets: ["PerfectZip", "minizip"]), soimport minizipworked. 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 newZipStatus.ZipEncryptionUnsupportedand writes nothing. A new overloadzipFiles(paths:zipFilePath:overwrite:)has no password. Upgrade: drop thepassword:argument. Passwords never worked in 3.x anyway (see Fixes). - New
ZipStatuscases:UnzipUnsafePath,UnzipLimitExceeded,UnzipIncorrectPasswordandZipEncryptionUnsupported.ZipStatusis nowCustomStringConvertible. Upgrade: an exhaustiveswitchneeds the new cases or adefault:. - 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
UnzipUnsafePathinstead 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
pathsis still followed. - The destination itself may be a symlink; it is resolved first.
- Extraction fails with
- Permissions and times.
zipFilesrecords 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.unzipFilerestores 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.unzipFilerestores 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
UnzipFailwhere 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 returnsZipCannotOverwrite; 3.x skipped the duplicate silently. __MACOSX/entries are skipped, files as well as directories.
- Damaged archives now return
- 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.unlimitedfor trusted input.
- These archives return
- 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: truereplaces 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-*.tmpfile may be left next to it. Likewise,zipFilesmay leave a.<name>.<UUID>.tmpnext tozipFilePath. - Ownership, ACLs and extended attributes of the old file are not kept.
- Archives with no entries work. 3.x couldn't open them.
ProcessedFilePathis deprecated and unused.
Fixes
Against 3.0.3 and the pre-release main (a2e1a0a):
- Zip Slip (path traversal): entries named
../../x,..\..\xora/../../xwere written outside the destination, andunzipFilereturnedZipSuccess. Reproduced onmainwith Python-made archives. An archive entrylink/xwas also written through a symlinklinkalready inside the destination. - Passwords never worked:
unzipFileignored itspasswordargument.zipFilespassed a CRC of 0 to the encryption header, so the check byte was wrong. Info-ZIPunzip -Preported "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
memcpyin the fork'sunzReadCurrentFile(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/bbehind a fileawas dropped andZipSuccessreturned. - Files over 4 GB: for a 5 GiB sparse file,
mainreportedZipSuccess, but its ownunzipFilereturnedUnzipFailon the result, and Info-ZIPunzip -treported "invalid compressed data to inflate". The same file now round-trips with matching CRC, andunzip -tand Python'stestzip()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.
- Passing
- Found in review before release:
- Hard links:
overwrite: truetruncated 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.
- Hard links:
- 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 installszlib1g-devand the interop tools. - README: install line, requirements, a security section, status codes and a 3.x migration section.
README.zh_CN.mdwas 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,bsdtarand Python's zipfile (stored, deflated, forced Zip64, streamed with data descriptors), and readingzip -Parchives. - 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,
__MACOSXAppleDouble 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 fromzipFilescalls and new cases handled in any exhaustiveswitchonZipStatus. - 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.