Skip to content

Replace ZIPFoundation with libarchive - #53

Merged
odrobnik merged 5 commits into
mainfrom
claude/zipfoundation-libarchive-migration-614615
Aug 17, 2026
Merged

Replace ZIPFoundation with libarchive#53
odrobnik merged 5 commits into
mainfrom
claude/zipfoundation-libarchive-migration-614615

Conversation

@odrobnik

Copy link
Copy Markdown
Contributor

Closes #46 (the Zip-container half; the PDF-deflate options in that issue are untouched).

Why

Package.swift pinned ZIPFoundation to an untagged development HEAD — the only commit carrying the Windows #import#include fix (weichsel/ZIPFoundation#380), without which the DOCX/Pages readers can't build on Windows. Upstream still hasn't tagged a release with it. This swaps in marcprux/swift-archive 3.8.9, which vendors libarchive as C sources behind a thin Swift wrapper, so there's no system libarchive to provision and we're back on a version tag.

GzipSupport is enabled explicitly — it's off by default upstream, and without zlib libarchive can't inflate DEFLATE entries, i.e. every real .docx/.epub.

What changed

Reading — DOCX parts, DOCX media, the iWork .iwa container — goes through ArchiveReader. libarchive is a sequential reader with no lookup by name, so each call streams the container once and keeps the entries it wants rather than seeking per part.

Writing .docx and .epub goes through the new SwiftTextZip target, which drives the raw archive_* C API rather than ArchiveWriter. ArchiveEntry.apply() unconditionally sets uid/gid/mtime, and libarchive emits a ux extra block for uid/gid and a UT block for any timestamp. Setting none of the three buys two things no public ArchiveWriter API can express:

  • A bare mimetype header. EPUB's OCF container forbids any extra field on that entry — the rule that keeps application/epub+zip at the fixed offset 38 reading systems sniff.
  • Reproducible output. libarchive derives the MS-DOS timestamp with localtime(), where ZIPFoundation used gmtime(). Stamping would make archives differ byte-for-byte between machines in different timezones, and pre-compensating the mtime would just move that machine-dependence into the UT field. Entries now date to the 1980 ZIP epoch everywhere; the real date still lives in the OPF's dcterms:modified, which is what readers surface. .docx gains this too — it was previously stamped with ZIPFoundation's Date() default and was never reproducible.

iWork writing stays on the hand-rolled StoredZipWriter. .pages needs stored entries with sizes and CRC in the local header plus Apple's exact per-entry metadata; libarchive always writes streaming entries with a trailing data descriptor (ZIP_ENTRY_FLAG_LENGTH_AT_END is unconditional — it never knows the CRC when the local header goes out). That file is untouched by this PR.

Output got ~9% smaller: real zlib compresses better than anything hand-rolled.

Reviewer notes

⚠️ Breaking: the platform floor rises to macOS 13 / iOS 15 / tvOS 15 / watchOS 10 (from macOS 12 / iOS 13 / tvOS 13 / watchOS 6). SwiftPM requires a package to be at least as new as any product it links, and swift-archive's own floor is macOS 13 / iOS 15. platforms: is package-wide, so the pure-Swift targets inherit it even though they don't link libarchive. README's Requirements section now states this explicitly.

The doubled product entry in SwiftTextZip is deliberate:

.product(name: "Archive", package: "swift-archive", condition: .when(traits: ["DOCX"])),
.product(name: "Archive", package: "swift-archive", condition: .when(traits: ["EPUB"]))

The target is shared by two independently-gated modules, and Swift 6.2's SwiftPM requires all traits in one .when(traits:) to be enabled (6.3 changed this to any-of). Spelling OR as separate dependencies keeps 6.2 working. Verified this doesn't leak: a consumer with traits: ["HTML"] still resolves only swift-markdown/cmark/XMLKit, so the README's lean-resolution claim holds.

Known and accepted: libarchive's zip reader has #ifdef DEBUG tracing, and SwiftPM passes -DDEBUG=1 to C targets in debug builds, so a handful of Header id 0x… lines go to stderr per archive that carries extra fields (Apple's .pages do; our own output doesn't). Release builds are silent, stdout is unaffected, and text output is byte-identical either way. Fixing it needs a one-line #undef DEBUG in swift-archive's config_spm.h — can't be done downstream, since cSettings don't apply to a dependency's target.

CI: Linux now installs zlib1g-dev + libssl-dev (libarchive links -lz and -lcrypto there). Windows already provisioned vcpkg zlib as z.lib, which is exactly what CArchive's .linkedLibrary("z") wants, so that job needed only comment updates.

Verification

  • 496 tests pass; SwiftLint --strict clean; iOS xcodebuild succeeds; portable subset (SWIFTTEXT_PORTABLE_ONLY=1) builds and runs its 153 tests
  • epubcheck: 0 fatals / 0 errors / 0 warnings on generated EPUBs; mimetype stored, extraLen 0, media type at offset 38
  • Python zipfile.testzip() (CRC-checks every entry) passes on generated .docx and .epub
  • textutil — Apple's own OOXML reader — parses the generated .docx
  • .docx byte-identical under TZ=UTC vs TZ=Pacific/Auckland; EPUB entry headers likewise. New test walks the central directory asserting no timestamps and no extra fields, so this can't silently regress
  • Real-world .pages and .docx read correctly through the release CLI

🤖 Generated with Claude Code

Swaps the ZIPFoundation dependency — pinned to an untagged `development`
HEAD for a Windows `#import` fix (weichsel/ZIPFoundation#380) — for
marcprux/swift-archive 3.8.9, which vendors libarchive as C sources
behind a thin Swift wrapper. The `GzipSupport` trait is enabled: it is
off upstream by default, and without zlib libarchive cannot inflate
DEFLATE entries, i.e. every real `.docx`/`.epub`.

Reading (DOCX parts, DOCX media, the iWork `.iwa` container) goes through
swift-archive's `ArchiveReader`. libarchive is a sequential reader with
no lookup by name, so each call streams the container once and keeps the
entries it wants instead of seeking per part.

Writing `.docx` and `.epub` goes through the new SwiftTextZip target,
which drives the raw `archive_*` C API rather than `ArchiveWriter`.
`ArchiveEntry.apply()` always sets uid/gid/mtime, and libarchive emits a
`ux` extra block for uid/gid and a `UT` block for any timestamp. Setting
none of the three buys two things no public `ArchiveWriter` API can:

  * A bare `mimetype` header. EPUB's OCF container forbids any extra
    field there — the rule that keeps `application/epub+zip` at the fixed
    offset 38 reading systems sniff.
  * Reproducible output. libarchive derives the MS-DOS timestamp with
    `localtime()`, where ZIPFoundation used `gmtime()`, so stamping would
    make archives differ between machines in different timezones. Entries
    now date to the 1980 ZIP epoch everywhere; the real date still lives
    in the OPF's `dcterms:modified`. `.docx` gains this too — it was
    previously stamped with `Date()` and never reproducible.

iWork writing stays on the hand-rolled `StoredZipWriter`: `.pages` needs
stored entries with sizes and CRC in the local header, which libarchive's
always-streaming ZIP writer cannot emit.

Since SwiftTextZip is shared by two independently-gated modules, it lists
the Archive product twice under DOCX and under EPUB — Swift 6.2's SwiftPM
requires ALL traits in one `.when(traits:)`, so OR semantics have to be
spelled as separate dependencies.

Platform floor rises to macOS 13 / iOS 15 / tvOS 15 / watchOS 10, from
macOS 12 / iOS 13 / tvOS 13 / watchOS 6: SwiftPM requires a package to be
at least as new as any product it links, and swift-archive's own floor is
macOS 13 / iOS 15. `platforms:` is package-wide, so the pure-Swift targets
inherit it even though they don't link libarchive.

Verified: 496 tests pass; epubcheck reports 0 errors / 0 warnings on
generated EPUBs; Python's zipfile `testzip()` passes on generated
`.docx`/`.epub`; `textutil` (Apple's own OOXML reader) parses the `.docx`;
`.docx` output is byte-identical under TZ=UTC and TZ=Pacific/Auckland and
EPUB entry headers likewise; real-world `.pages` and `.docx` read
correctly; iOS builds; the portable subset and the HTML-only trait subset
still resolve without libarchive.

Output shrinks ~9%: real zlib beats the fixed-Huffman encoder the interim
in-house writer would have used.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 278e08b0f2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Package.swift
Comment thread Sources/SwiftTextZip/ZipContainerWriter.swift Outdated
odrobnik and others added 3 commits August 17, 2026 14:49
`write(_:to:)` built the archive beside the destination, then read the
finished file back into a `Data` and wrote every byte a second time. On an
image-heavy `.docx`/`.epub` that doubles peak memory on top of the entry
data the callers already hold.

libarchive streams straight to disk, so the staging file is already the
final artifact — swap it in with `replaceItemAt` (or `moveItem` when there
is nothing to replace). Staging as a sibling of the destination keeps it on
the same volume, making that a rename rather than a copy, and it stays
atomic either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Requirements section said "Swift 5.9+", but the manifest declares
`swift-tools-version:6.1` — a 5.9 toolchain can't parse it at all, so the
claim was never true. State 6.1+, and note that CI builds on 6.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`.when(traits:)` is any-of as of the 6.3 tools version, so `SwiftTextZip`
no longer needs the same product listed twice to mean "DOCX or EPUB" —
one condition covers both. Drops the 6.2-era commentary from the other
trait-gated targets too, which was explaining a workaround that no longer
applies.

Safe for every CI job: macOS and iOS run Xcode latest-stable (Swift 6.3.3
as of the last green run on main), Linux is swift:6.3-jammy, and Windows
and Android pin 6.3.1. The macOS job's comment claiming latest-stable is
6.2.x was stale; corrected.

Also restores Package.resolved. A local `SWIFTTEXT_PORTABLE_ONLY=1 swift
test` rewrites the lockfile down to the portable dependency set, and
re-resolving from that pruned state had silently bumped
swift-argument-parser 1.7.0 → 1.8.2 and XMLKit 1.0.0 → 1.0.1. The lockfile
now differs from main by the ZIPFoundation → swift-archive swap alone.

Verified: 496 tests; portable subset (153 tests); SwiftLint --strict; iOS
build; and each trait subset resolves correctly — DOCX alone and EPUB
alone each pull libarchive in and compile SwiftTextZip, while HTML alone
resolves neither swift-archive nor argument-parser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@odrobnik

Copy link
Copy Markdown
Contributor Author

Bumped to swift-tools-version:6.3 in 164e9a2, so .when(traits:) is any-of and SwiftTextZip no longer lists the Archive product twice — one .when(traits: ["DOCX", "EPUB"]) covers both. The 6.2-era commentary on the other trait-gated targets is gone too.

Checked before bumping: the macOS job's comment claiming latest-stable is Swift 6.2.x was stale — the last green run on main reports 6.3.3 on both macOS and iOS, Linux is swift:6.3-jammy, and Windows/Android pin 6.3.1. Comment corrected.

Also restores Package.resolved: a local SWIFTTEXT_PORTABLE_ONLY=1 swift test rewrites the lockfile down to the portable dependency set, and re-resolving from that pruned state had quietly bumped swift-argument-parser 1.7.0 → 1.8.2 and XMLKit 1.0.0 → 1.0.1. It now differs from main by the ZIPFoundation → swift-archive swap alone.

Re-verified: 496 tests, portable subset (153), SwiftLint --strict, iOS build, and each trait subset — DOCX alone and EPUB alone each pull libarchive in and compile SwiftTextZip, while HTML alone resolves neither swift-archive nor argument-parser.

🤖 Posted by Claude Code

The file was copied from another project and never adapted: it told agents
to run `swift test` for "BrickCore" changes and to build
`SwiftLEGO.xcodeproj` with the `SwiftLEGO` scheme. SwiftText has no Xcode
project at all, so that command could never have worked here.

Its "ship for iOS 26 and newer" line was SwiftLEGO's floor too. Taken at
face value it is wrong by eleven major versions — the package has always
targeted low deployment targets on purpose (macOS 12 / iOS 13 before the
libarchive migration, macOS 13 / iOS 15 now), with Linux, Windows and
Android CI jobs. An automated reviewer already cited that line against
this PR, so it was actively misleading readers, not just stale.

Dropped the pluralization rule as well: it prescribes SwiftUI's
`Text("^[\(n) part](inflect: true)")`, and there is no SwiftUI anywhere in
this package.

Kept what is true (structured concurrency; Swift Testing only — verified:
zero XCTest imports, 63 files using Swift Testing) and added what an agent
here actually needs: the real deployment floors and why they are set, the
cross-platform constraint on Apple-only frameworks, the 6.3 toolchain
requirement, the trait-gating convention for dependencies, and the
`SWIFTTEXT_PORTABLE_ONLY` subset — including that it rewrites
Package.resolved, which silently bumped two dependencies earlier in this
branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@odrobnik
odrobnik merged commit b4c132f into main Aug 17, 2026
7 checks passed
@odrobnik
odrobnik deleted the claude/zipfoundation-libarchive-migration-614615 branch August 17, 2026 16:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Explore adopting SwiftPorts compression to replace the custom PDF deflate + ZIPFoundation

1 participant