PCArchive¶
PCArchive is Peach Commander's archive engine. It lets the app browse an
archive as if it were a folder (read-only), extract it, create new
archives, and perform limited in-place edits of zip files. It sits above
PCVFS — its central abstraction, ArchiveFS, is a VirtualFileSystem — and
below PCApp, which drives the pack/unpack/edit commands and password prompts.
Purpose and responsibility¶
The module has four responsibilities, each with a distinct entry point:
- Browse — present a zip/tar/… file as a mounted, read-only filesystem
(
ArchiveFS), backed by pluggable readers. - Extract — walk any such filesystem to disk (
ArchiveExtractor). - Pack — create archives in many formats by driving external CLI packers
(
PackEngine). - Edit — add/remove/rename entries inside an existing zip by full rewrite
(
ArchiveEditor).
Reading of the two "hot" formats (zip and tar/tar.gz) is implemented in pure
Swift with no third-party dependency — parsing uses Foundation Data, and
DEFLATE inflate/deflate use the system Compression framework
(compression_decode_buffer / compression_encode_buffer with
COMPRESSION_ZLIB, which produces raw RFC 1951 DEFLATE). Encryption uses
CommonCrypto. Everything else (exotic read formats, all packing) is
delegated to system command-line tools.
Note on ADR-005.
DECISIONS.md(ADR-005) frames PCArchive as "wraps libarchive for read". In the current source, PCArchive does not linklibarchivedirectly. Instead it (a) ships its own random-accessZipReader/ZipWriterand aTarReader(the fast in-archive browsing path ADR-005 anticipated), and (b) reaches the remaining libarchive-supported formats by shelling out tobsdtar(which is libarchive) rather than linking the library. Packing shells out to7z/tar/rar. The ADR's intent (one battle-tested engine for the long tail, our own reader for speed) holds; the mechanism is subprocess invocation, not linkage. Treat this as the authoritative description.
Dependencies¶
graph TD
PCApp --> PCArchive
PCArchive --> PCVFS
PCArchive --> PCFoundation
PCVFS --> PCFoundation
PCArchive -. subprocess .-> bsdtar
PCArchive -. subprocess .-> sevenzip["7z / 7za"]
PCArchive -. subprocess .-> tar
PCArchive -. subprocess .-> rar
Needs (linked): PCFoundation and PCVFS. ArchiveFS conforms to
PCVFS's VirtualFileSystem protocol and produces VFSEntry / VFSPath /
VFSEntryBatch / VFSReadStream values; it throws VFSError. System
frameworks: Compression (DEFLATE) and CommonCrypto (WinZip AES). Declared in
project.yml — the target has no third-party linkage.
Needs (runtime, optional): external binaries discovered on PATH-like
directories: bsdtar (browse of non-zip/tar formats), 7z/7za, tar, rar
(packing). Absence degrades gracefully — a format simply becomes unavailable.
Depended on by: PCApp only. Concretely
Controllers/MainWindowController.swift,
Controllers/PanelController+Operations.swift, PackOptionsDialog.swift, and
SyncWindowController.swift (zip-sync). The file-search engine also opens
archives through an ArchiveFS opener to search inside them (see
SearchInArchiveTests).
Public interfaces and key types¶
ArchiveFS — read-only VFS over an archive¶
public final class ArchiveFS: VirtualFileSystem, @unchecked Sendable
(ArchiveFS.swift). Presents a zip or tar archive as a filesystem rooted at
/, scheme = "zip", capabilities = [.read].
init?(archiveFileURL:fsID:)— opens the file. Format detection is extension-aware: for extensions inShellArchiveSource.handledExtensions(cpio,iso,cab,lzh,lha,xar,pax,ar,cpgz,img) it triesShellArchiveSourcefirst, otherwiseZipReaderthenTarReaderthenShellArchiveSource. Extension-first ordering exists because the lenientTarReaderwould otherwise mis-claim libarchive-only formats (F-130). Returnsnilwhen nothing can parse the file.- On open it eagerly builds an in-memory tree: one
Nodeper member keyed by normalized full path, plus achildOrderadjacency map. Because many archives omit explicit directory entries, intermediate directory nodes are synthesized from each member's path (ensureDirectory). VirtualFileSystemconformance:list(yields a singleVFSEntryBatchfrom the pre-built tree),stat,openRead(returnsArchiveReadStream),localFileIfAvailable(materializes a member to a temp file). All mutating operations —openWrite,mkdir,delete,rename,setAttributes— throwVFSError.unsupported.watchreturnsnil.- Encryption support:
var password: String?(set by the host after prompting),hasEncryptedEntries: Bool, andpasswordIsValid() -> Bool(tries the first encrypted member so a Keychain-remembered password can be validated before use — F-136).
ArchiveReadStream (final class … VFSReadStream) chunks already-decompressed
in-memory bytes at 1 MiB, mirroring LocalReadStream's shape.
ArchiveSource / ArchiveMember — the reader abstraction¶
ArchiveSource.swift defines the format-agnostic backend contract so
ArchiveFS shares its tree/list/stat/read logic across formats:
public struct ArchiveMember: Sendable— metadata only: normalizedpath(no leading/; directories end/),uncompressedSize,isDirectory,modified: Date?,isEncrypted.public protocol ArchiveSource: AnyObject—var members: [ArchiveMember]andfunc data(atIndex:password:) throws -> Data. The index intomembersis the byte-access handle.
Three conformers: TarReader (direct), ShellArchiveSource (direct), and
ZipReader via an adapter extension in the same file (member order mirrors
ZipReader.entries, so one index serves both).
ZipReader — pure-Swift zip parser/extractor¶
public final class ZipReader (ZipReader.swift). Parses the classic
(non-ZIP64) End-Of-Central-Directory → central-directory layout via a
bounds-checked little-endian ByteReader, exposing let entries: [ZipEntry].
init?(fileURL:)returnsnilfor a missing EOCD or malformed central directory.findEOCDscans backwards allowing up to a 64 KB archive comment.data(for:password:)seeks to the local header, reads the compressed bytes, decrypts if needed, then inflates. Supported methods: store (0) and DEFLATE (8); anything else throwsZipError.unsupportedCompression.- Encryption: classic PKWARE ZipCrypto (12-byte header, check byte against
CRC or mod-time high byte for streamed/data-descriptor entries — see
ZipCryptoKeys) and WinZip AES (method 99; strength and real method read from the0x9901extra field, decrypted viaWinZipAES). verify() -> [IntegrityProblem]decompresses every entry and checks size + CRC-32, backing the "Test archive" command (F-135).static func inflate(_:expectedSize:)is internal soTarReadercan reuse it for gzip payloads.- Errors:
enum ZipError—malformed,unsupportedCompression,inflateFailed,passwordRequired,wrongPassword,encryptedAES. - Scope limits (intentional MVP): no ZIP64, no multi-disk; filenames decoded UTF-8 with Latin-1 fallback (not full CP437).
ZipWriter — pure-Swift zip writer¶
public enum ZipWriter (ZipWriter.swift). create(at:files:) takes
[(path:String, data:Data)] and writes the same non-ZIP64 layout ZipReader
reads. Each entry is DEFLATE-compressed only when that is smaller than the raw
bytes (else stored); a path ending in / with empty data is a directory entry.
Sets the UTF-8 filename flag (GP bit 11). It also owns the shared crcTable and
crc32(of:) (reused by ZipReader.verify() and ZipCryptoKeys). Errors:
enum ZipWriteError. Scope: no ZIP64, no encryption, no comment.
TarReader — pure-Swift tar / tar.gz reader¶
public final class TarReader: ArchiveSource (TarReader.swift). Parses POSIX
ustar and GNU tar 512-byte blocks; transparently gunzips a 1f 8b stream
first (payload is raw DEFLATE, sized from the gzip ISIZE trailer, inflated via
ZipReader.inflate). Handles GNU long names (L), pax extended headers
(x/g path=), the ustar prefix+name split, old-style v7 headers via a
heuristic, and ./-prefixed members. Member data are returned as slices of the
in-memory tar buffer. init? returns nil when no valid header is found so the
opener can fall through to another format.
ShellArchiveSource — bsdtar fallback¶
public final class ShellArchiveSource: ArchiveSource (ShellArchiveSource.swift).
For the libarchive long-tail formats. Lists via bsdtar -tvf (one verbose
parser covers every format) and extracts a single member via bsdtar -xOf to
stdout, keying off the raw listed member string. Locates bsdtar (or tar)
under /usr/bin. init? returns nil if the tool is missing or the listing is
empty.
ArchiveExtractor — extract a VFS tree to disk¶
public enum ArchiveExtractor (ArchiveExtractor.swift). extractAll(from:to:)
recursively walks any VirtualFileSystem (typically an ArchiveFS) writing each
member under destination, returning Result(files:bytes:). Backs
cm_UnpackFiles. It is async, honors Task cancellation between entries, and
caps a single member at 512 MiB (perFileLimit) to guard against a crafted
"zip-bomb" size claim.
PackEngine — create archives via external tools¶
public enum PackEngine (PackEngine.swift). pack(items:to:options:) drives
a system packer through Foundation.Process.
enum PackFormat:zip,sevenZip,tar,tarGz,tarBz2,tarXz,rar, each withfileExtension,supportsEncryption,supportsSplit.struct PackOptions:format,password?,splitSize?(bytes/volume),level(0–9).- Tooling: tar family →
tar(-cf/-czf/-cjf/-cJf);zip/sevenZip→7z/7za(AES-256 via-mem=AES256/-mhe=on, split via-v…b);rar→ the proprietaryrarbinary. Tools are resolved bytoolPath(_:)across/opt/homebrew/bin,/usr/local/bin,/usr/bin,/bin. enum PackError:toolNotFound,unsupportedOption,noItems,failed(String, Int32)(stderr + exit code). Backscm_PackFiles/PackOptionsDialog(F-132/F-136/F-138). Items must share a parent directory; the process runs from that parent so entries are stored by basename, and any pre-existing target (including split volumes) is removed first.
ArchiveEditor — in-place zip edits¶
public enum ArchiveEditor (ArchiveEditor.swift). Because zip has no cheap
in-place mutation, remove, rename, and add all read surviving entries
through ZipReader and re-emit the whole archive with ZipWriter (F-133;
copy-into-archive F-139). add walks local directories recursively.
enum ArchiveEditError.unreadableArchive. Caveat: the rewrite does not
preserve modification timestamps (ZipWriter stamps write time); byte contents
are preserved exactly.
WinZipAES — AES-encrypted zip decryption¶
enum WinZipAES (WinZipAES.swift, internal). decrypt(_:password:strengthCode:)
implements the AE-1/AE-2 body layout (salt | 2-byte verifier | AES-CTR
ciphertext | 10-byte HMAC-SHA1) using CommonCrypto PBKDF2-HMAC-SHA1 (1000 iters)
for key derivation and a hand-rolled little-endian 128-bit CTR (CommonCrypto's
built-in CTR is big-endian, so the keystream is computed manually via ECB).
Verifier or HMAC mismatch → ZipError.wrongPassword.
Inputs and outputs¶
| Concern | In | Out |
|---|---|---|
| Browse | archive file URL + fsID |
an ArchiveFS (a VirtualFileSystem) |
| Read member | VFSPath |
ArchiveReadStream of Data chunks |
| Extract | a VirtualFileSystem + destination URL |
files on disk + ArchiveExtractor.Result |
| Pack | local paths + PackOptions |
an archive file (or split volumes) |
| Edit | zip URL + path/entry lists |
rewritten zip at the same URL |
| Verify | (implicit, whole archive) | [ZipReader.IntegrityProblem] |
Lifecycle¶
ArchiveFS is short-lived and load-once: init? reads the whole file (or the
tar bytes) into memory and materializes the full node tree up front; there is no
lazy/streaming central-directory scan. Subsequent list/stat are pure
in-memory lookups, and openRead decompresses on demand from the retained
bytes. There is no explicit teardown — the instance and its buffer are released
when the mount is dismissed. Temp files created by localFileIfAvailable land
under FileManager.temporaryDirectory in a per-mount subdirectory and are the
caller's to clean up. PackEngine and ArchiveEditor are stateless enums
operating per call.
Implication: because reads hold the entire (uncompressed, for tar) archive in memory, PCArchive is tuned for interactive browsing of ordinary archives, not for streaming multi-gigabyte files.
Threading and concurrency¶
ArchiveFSis@unchecked Sendable: afterinit, itsnodes/childOrderare immutable; the only mutable member ispassword, expected to be set by the host before reads.listis synchronous internally but exposed as anAsyncThrowingStream;stat/openRead/localFileIfAvailableareasyncto satisfy the protocol though the work is CPU/IO-bound and synchronous.ArchiveExtractor.extractAllisasyncand cooperatively cancellable (Task.isCancelledchecked per entry and per chunk).PackEngine.packandShellArchiveSourceblock onProcess.waitUntilExit(); callers (inPCApp) run them off the main actor.TarReader/ZipReader/ZipWriterare not internally synchronized; instances are used from a single task at a time.
Error handling¶
Errors are typed per concern: ZipError, ZipWriteError, PackError,
ArchiveEditError, and VFSError (surfaced by the ArchiveFS protocol
methods — notably .notFound, .unsupported). Detection failures are modeled
as nil from failable initializers (ZipReader/TarReader/ShellArchiveSource/
ArchiveFS init?) so the opener can cascade through formats. Diagnostic
strings in ZipError.malformed are explicitly not for end-user display.
Integrity is a first-class, non-throwing result via verify().
How it is tested¶
Tests live under Tests/PCArchiveTests (part of the module suite):
ZipReaderTests,ZipWriterTests— round-trips against the pure-Swift reader/writer.TarReadTests— ustar/GNU/pax and tar.gz parsing.ZipCryptoTests,ZipAESReadTests— classic ZipCrypto and WinZip AES decryption (fixtures produced by the systemzip/7z, skipped when the tool is absent viaXCTSkipUnless).ArchiveFSTests— tree building, listing, stat, read over synthesized dirs.ArchiveExtractorTests— full-tree extraction and the byte/file counts.PackEngineTests— format/args/encryption/split behavior (tool-gated).ArchiveEditorTests— add/remove/rename rewrite semantics.SearchInArchiveTests— the search engine descending into anArchiveFS.
Many tests generate fixtures with real system tools and skip when those tools are unavailable, so the suite stays green on a minimal machine.
Extension points¶
- New read format: conform a new type to
ArchiveSourceand slot it intoArchiveFS.init?'s detection cascade; the tree/list/stat/read machinery is then reused unchanged. - New pack format: add a
PackFormatcase and a branch inPackEngine.command(for:…);toolPathalready handles binary discovery. - Wider libarchive coverage without new code: add the extension to
ShellArchiveSource.handledExtensions(assumingbsdtarreads it). - Encryption: extend
WinZipAES/ZipCryptoKeysfor new schemes behind the existingdata(for:password:)decrypt-then-inflate seam.
Open questions¶
- ZIP64 / large archives. The native reader and writer are non-ZIP64 by
design; archives above 4 GiB (or with >65535 entries) are out of scope and the
tar gunzip path assumes a <4 GiB uncompressed size (ISIZE truncation). A
future ZIP64 path, or routing large archives through
bsdtar, is unspecified. - Whole-file in-memory model. Streaming reads for very large archives are not implemented; whether to add a chunked path is open.
- ADR-005 wording. The ADR says "wraps libarchive"; the implementation uses bsdtar-as-subprocess plus in-house readers (see the note above). The ADR text is stale relative to the source.