Split out of #130 (PR #154), from cross-model review.
What #130 actually bounds
ZipHelper.unzip checks the archive's declarations before extracting, and measures the extracted tree afterwards. Both refuse. But they refuse at different moments, and only the first one is a bound:
| archive |
when it is refused |
bytes written first |
| declares more than the limit |
before extraction |
none |
| understates its entries |
after extraction, from the walk |
all of them |
The central directory can lie — ZipHelper.swift says so in its own comment — so a package that declares 1 KB and writes 4 MB gets its 4 MB onto disk, and is only then refused. Scale that: a package can consume the volume, or the 1.7 GB from #130's own PoC, and the refusal arrives afterwards. That is post-hoc validation, not "how far a package may expand".
The actual compression ratio is not checked at all. An entry declaring a modest size under the 500× declared-ratio limit can still emit 200 MiB from a tiny compressed stream, pass the 256 MiB actual entry limit, and have a real ratio well over 500×.
What it needs
Count bytes as the decompressor produces them and abort the extraction as soon as a per-entry or aggregate limit is crossed. The post-extraction walk can stay as defence in depth; it just cannot be the bound.
The easy route does not work — measured
ZIPFoundation's unzipItem(at:to:…, progress: Progress?) looks sufficient: its helpers check progress?.isCancelled and throw ArchiveError.cancelledOperation, and Archive+Helpers.swift adds actual bytes (progress?.completedUnitCount += Int64(data.count)).
But unzipItem builds totalUnitCount from archive.totalUnitCountForReading(_:) — the declared sizes — and aggregates per-entry children with addChild(_:withPendingUnitCount:). For an archive that understates, each child saturates against its own declared total, so the parent's count stops rising exactly in the case that matters. Observing the parent progress therefore cannot detect the overflow it exists to detect.
So this needs per-entry Archive.extract(_:bufferSize:consumer:) with our own counter, which means re-doing the extraction path that carries #137–#139's hardening (symlink refusal, owner-only descriptors, the private copy, the strict walk). That is why it is not folded into #130 — it is a rewrite of security-critical code, and #130's own history is twelve fix rounds of exactly that kind of change going wrong one round at a time.
Meanwhile
PR #154's CHANGELOG now states the boundary rather than implying a bound: a declaration over the limit is refused before any byte is written; an understating archive is caught after those bytes have landed.
Refs #130
Split out of #130 (PR #154), from cross-model review.
What #130 actually bounds
ZipHelper.unzipchecks the archive's declarations before extracting, and measures the extracted tree afterwards. Both refuse. But they refuse at different moments, and only the first one is a bound:The central directory can lie —
ZipHelper.swiftsays so in its own comment — so a package that declares 1 KB and writes 4 MB gets its 4 MB onto disk, and is only then refused. Scale that: a package can consume the volume, or the 1.7 GB from #130's own PoC, and the refusal arrives afterwards. That is post-hoc validation, not "how far a package may expand".The actual compression ratio is not checked at all. An entry declaring a modest size under the 500× declared-ratio limit can still emit 200 MiB from a tiny compressed stream, pass the 256 MiB actual entry limit, and have a real ratio well over 500×.
What it needs
Count bytes as the decompressor produces them and abort the extraction as soon as a per-entry or aggregate limit is crossed. The post-extraction walk can stay as defence in depth; it just cannot be the bound.
The easy route does not work — measured
ZIPFoundation's
unzipItem(at:to:…, progress: Progress?)looks sufficient: its helpers checkprogress?.isCancelledand throwArchiveError.cancelledOperation, andArchive+Helpers.swiftadds actual bytes (progress?.completedUnitCount += Int64(data.count)).But
unzipItembuildstotalUnitCountfromarchive.totalUnitCountForReading(_:)— the declared sizes — and aggregates per-entry children withaddChild(_:withPendingUnitCount:). For an archive that understates, each child saturates against its own declared total, so the parent's count stops rising exactly in the case that matters. Observing the parent progress therefore cannot detect the overflow it exists to detect.So this needs per-entry
Archive.extract(_:bufferSize:consumer:)with our own counter, which means re-doing the extraction path that carries #137–#139's hardening (symlink refusal, owner-only descriptors, the private copy, the strict walk). That is why it is not folded into #130 — it is a rewrite of security-critical code, and #130's own history is twelve fix rounds of exactly that kind of change going wrong one round at a time.Meanwhile
PR #154's CHANGELOG now states the boundary rather than implying a bound: a declaration over the limit is refused before any byte is written; an understating archive is caught after those bytes have landed.
Refs #130