Close the container-layer compression-method integrity holes - #104
Merged
Conversation
An entry whose bytes could never be obtained was reported CLEAN, with its
CRC-32 and every hash over those bytes silently unverified. `inflateEntry`
treated EVERY non-Store method as raw Deflate, so a Zstandard entry — which
Container Format §3.2 permits and RECOMMENDS — threw a confusing zlib error
that both callers swallowed.
The Zstandard case turned out to be one of five holes in that class:
- method 93 fed to raw-inflate, swallowed, clean;
- a method outside §3.2's set (BZIP2, LZMA, …) accepted without complaint;
- bytes undecodable under a SUPPORTED method, reported clean — the case an
earlier comment wrongly claimed the LFH/CD and bomb checks already owned;
- a decompression bomb whose DECLARED size lied, filed as "corruption"
(declared sizes are attacker-controlled, so the output bound is the
defence that actually holds);
- out-of-range and negative local-header offsets throwing a bare RangeError
that every `instanceof InflateError` guard then dropped silently.
`inflateEntry` now dispatches on the actual method and classifies every
failure into a typed error, because the kinds carry OPPOSITE dispositions: a
forbidden method or undecodable bytes reject the container, a bomb is its own
row, and a permitted method this reader has not implemented is not a defect at
all. Zstandard support is feature-detected — §3.2 makes it RECOMMENDED, not
required, so a reader without it is conformant — and the suite already models
that with the `compression:zstd` capability.
SPEC. §3.2 now states that its table is the complete permitted set and that a
reader MUST NOT infer a method from the data. This is a normative NARROWING:
"MAY use the following" granted permission without forbidding others, and
§3.1's APPNOTE.TXT conformance defines BZIP2, LZMA, XZ and PPMd. §5.4.2 gains
two REJECT rows, so neither sibling failure rests on analogy, and a new note 6
covers the opposite case. Note 6 carries note 5's carve-out verbatim in
substance — the missing-part rows MUST NOT fire for a present-but-undecodable
part — because without it a Deflate-only reader received two contradictory
instructions for one archive: integrity-indeterminate ("document unaffected")
from the container layer and PART-MISSING-BOUND from the document layer, for a
part that is present and intact. In B3 that would escalate to an
INTEGRITY-ERROR accusing an undamaged archive of tampering on the strength of
the READER's capability envelope. §5.4.1 gains a paragraph stating that
integrity-indeterminate is not one of the four dispositions.
That carve-out required a new `unobtainable` part-load status, distinct from
`defect`, threaded through every consumer.
FIXTURE STABILITY. The Zstandard fixture hand-builds a raw-block frame rather
than calling the compressor: zstd guarantees decodability across versions, not
encoder byte-identity, and `check:fixtures` byte-compares every committed
archive against a fresh build. It also means fixtures build on any Node.
TOOLCHAIN. CI moves to Node 22 (zstdDecompressSync lands in 22.15) and pins
Python 3.14 (the oracle decodes Zstandard with compression.zstd). On an older
Python the oracle reports the Zstandard case UNCONFIRMED by name rather than
confirming bytes it never decoded — a limitation stated, not hidden.
errors.json v0.10 (75 codes). The vocabulary now uses `disposition: null` for
two distinct situations, and the new entry says which it is: a specification
gap, versus a deliberate non-defect where the spec does answer and the answer
is that the document is not at fault.
201 vectors + 123 fixtures; archive oracle 22 defects; 28 gates.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
An archive entry whose bytes could never be obtained was reported CLEAN, with its CRC-32 and every hash over those bytes silently unverified.
inflateEntrytreated every non-Store method as raw Deflate, so a Zstandard entry — which Container Format §3.2 permits and RECOMMENDS — threw a confusing zlib error that both callers swallowed.The Zstandard case turned out to be one of five holes in that class:
RangeErrorthat everyinstanceof InflateErrorguard then droppedinflateEntrynow dispatches on the actual method and classifies every failure into a typed error, because the kinds carry opposite dispositions: a forbidden method or undecodable bytes reject the container, a bomb is its own row, and a permitted method this reader has not implemented is not a defect at all. Zstandard support is feature-detected — §3.2 makes it RECOMMENDED, not required, so a reader without it is conformant — and the suite already models that with thecompression:zstdcapability.Declared sizes are attacker-controlled, so the §9.2 pre-pass cannot catch a bomb that understates itself; the actual output bound is the defence that holds, and it now reports under the bomb row rather than as corruption.
Normative change — this is a NARROWING
§3.2 now states that its table is the complete permitted set and that a reader MUST NOT infer a method from the data (in particular MUST NOT treat an unrecognized method as Deflate).
That is a genuine narrowing, not a clarification.
"MAY use the following compression methods"granted permission for those three without by itself forbidding others, and §3.1's required conformance to APPNOTE.TXT 6.3.3+ defines BZIP2 (12), LZMA (14), XZ (95) and PPMd (98). An archive using one of those was arguably conformant before and is not now. Recorded inCHANGELOG.mdunder### Changed.§5.4.2 gains two REJECT rows — one for a forbidden method, one for an entry that will not decode under a permitted one — so neither sibling failure rests on analogy. §5.4.1 gains a paragraph stating that integrity-indeterminate is not one of the four dispositions.
The blocking review finding: note 5 vs note 6
The first draft of note 6 contradicted the existing note 5 on the identical question — a part that is present but whose bytes this consumer cannot obtain.
Reproduced: a Deflate-only reader on a hash-bound Zstandard presentation layer received
CDX-E-ARCHIVE-ENTRY-INTEGRITY-INDETERMINATE(null disposition, "document unaffected") from the container layer andCDX-E-PART-MISSING-BOUNDfrom the document layer — for a part that is present and intact. In B3 that escalates to an INTEGRITY-ERROR accusing an undamaged archive of tampering, purely on the strength of the reader's capability envelope.Note 6 now carries note 5's carve-out in substance — the missing-part rows MUST NOT fire — plus note 5's document-level conclusion where the entry carries required or hash-bound content, and an explicit carve-out from §6.1's otherwise unconditional CRC MUST. That required a new
unobtainablepart-load status, distinct fromdefect, which the type checker then forced through every consumer.Fixture byte-stability
The Zstandard fixture hand-builds a raw-block frame rather than calling the compressor. zstd guarantees decodability across versions, not encoder byte-identity, and
check:fixturesbyte-compares every committed archive against a fresh build — which is exactly why the recipe docs already refuse to commit Deflate output. It also means fixtures build on any Node.Toolchain
zlib.zstdDecompressSynclands in 22.15);engines→>=22.15.0,@types/node→^22, which also removed two casts.compression.zstd). On an older Python — including the 3.9 macOS ships — the archive oracle still runs and still fails on every defect it can detect, but reports the Zstandard case asUNCONFIRMEDby name rather than confirming bytes it never decoded.CONTRIBUTING.mdgains a Prerequisites section.test:container-reader, for the same reasontest:document-verdictexists: a null-disposition code cannot be asserted through a fixture, and the indeterminate arm only arises on a runtime lacking the decoder.errors.json→ v0.10 (75 codes). The vocabulary now usesdisposition: nullfor two distinct situations, and the new entry says which it is: a specification gap, versus a deliberate non-defect where the spec does answer and the answer is that the document is not at fault.Review
3 cycles, 3 independent reviewers, 24 findings addressed, 12 mutants killed. Two were corrections to my own work: a comment claiming the typed error survives a bad offset (true only for a forbidden method) with a test that asserted only that case and so passed vacuously; and an oracle guard proven by mutation to be vacuous on CI's Python — zeroing a payload went undetected.
Two guards remain unmutatable and are documented as such rather than claimed as covered.
Test plan
validate-schemas.ymlparity) +generate:template --preset allcheck:conformance— 324/324 (201 vectors + 123 fixtures), 0 skippedcheck:archive-oracle— 22 injected defects + 6 clean, selftest passingtest:container-reader8 ·test:document-verdict22 ·test:canonicalize100check:fixtures— committed archives re-derive byte-for-byte, including the hand-built Zstandard frame