Skip to content

Close the container-layer compression-method integrity holes - #104

Merged
gvonnessi merged 1 commit into
mainfrom
fix/container-compression-method-integrity
Jul 30, 2026
Merged

Close the container-layer compression-method integrity holes#104
gvonnessi merged 1 commit into
mainfrom
fix/container-compression-method-integrity

Conversation

@gvonnessi

Copy link
Copy Markdown
Collaborator

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. 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:

Case Before
Method 93 (Zstandard) fed to raw-inflate swallowed → clean
A method outside §3.2's set (BZIP2, LZMA, …) accepted without complaint
Bytes undecodable under a supported method 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"
Out-of-range / negative local-header offsets bare RangeError that every instanceof InflateError guard then dropped

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.

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 in CHANGELOG.md under ### 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 and CDX-E-PART-MISSING-BOUND from 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 unobtainable part-load status, distinct from defect, 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:fixtures byte-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

  • CI Node 20 → 22 (zlib.zstdDecompressSync lands in 22.15); engines>=22.15.0, @types/node^22, which also removed two casts.
  • CI Python pinned to 3.14 (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 as UNCONFIRMED by name rather than confirming bytes it never decoded. CONTRIBUTING.md gains a Prerequisites section.
  • New gate test:container-reader, for the same reason test:document-verdict exists: a null-disposition code cannot be asserted through a fixture, and the indeterminate arm only arises on a runtime lacking the decoder.

errors.jsonv0.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.

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

  • 28 gates green (validate-schemas.yml parity) + generate:template --preset all
  • check:conformance — 324/324 (201 vectors + 123 fixtures), 0 skipped
  • check:archive-oracle — 22 injected defects + 6 clean, selftest passing
  • test:container-reader 8 · test:document-verdict 22 · test:canonicalize 100
  • check:fixtures — committed archives re-derive byte-for-byte, including the hand-built Zstandard frame
  • Verified on both a zstd-capable and a Deflate-only runtime, and on Python 3.9 and 3.14

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.
@gvonnessi
gvonnessi merged commit f6bc210 into main Jul 30, 2026
2 checks passed
@gvonnessi
gvonnessi deleted the fix/container-compression-method-integrity branch July 30, 2026 08:26
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.

1 participant