Skip to content

oci: Decode multi-frame compressed layers - #381

Merged
cgwalters merged 1 commit into
composefs:mainfrom
andrewdunndev:fix/multi-frame-decompress
Aug 25, 2026
Merged

oci: Decode multi-frame compressed layers#381
cgwalters merged 1 commit into
composefs:mainfrom
andrewdunndev:fix/multi-frame-decompress

Conversation

@andrewdunndev

Copy link
Copy Markdown
Contributor

zstd:chunked OCI layers are multi-frame zstd streams: one frame per chunk, plus skippable frames carrying the TOC and footer. decompress_async() builds its decoders with the async-compression defaults, which stop at the first frame boundary, so pulling a chunked layer truncates the decompressed tar and fails with "unexpected EOF reading tar entry" (bootc-dev/bootc#2408). Multi-member gzip streams hit the same latent truncation.

This enables multiple_members(true) on both decoders, so concatenated frames and members decode to the full stream, and skippable frames are skipped (libzstd handles them natively at any position). The async-compression floor moves to 0.4.43, which fixed an infinite loop on a corrupt later frame with multiple_members enabled (Nullus157/async-compression#470).

This is full-pull correctness only: a chunked image now pulls and mounts, but nothing exploits the TOC for partial pulls, which remains #137 and #356 territory. It is the interim described in bootc-dev/bootc#509, where the host "at least doesn't barf on them (but it won't have optimized fetches)". Without it, a composefs host whose publisher switches to zstd:chunked is stranded: the pull that would deliver any fix is the pull that fails, and recovery is publisher-side re-push or reinstall.

It also matches what already works on the OSTree backend. ostree-ext's Decompressor routes zstd through libzstd streaming, which decodes multiple frames and skips skippable framing natively (its own comments call out zstd:chunked's trailing metadata frames, bootc-dev/bootc#1204), and reusing that logic in composefs-rs was suggested in bootc-dev/bootc#1703. The tar import here already drains the decompressed stream to EOF after the archive terminator for diff_id fidelity, so the trailing-frame pipe hazard #1204 describes is covered on the proxy path as well.

The new tests synthesize multi-frame zstd (skippable frames leading, interleaved, and trailing), multi-member gzip, and a corrupt second frame under a timeout; the multi-member cases each fail with the fix reverted. Also verified end-to-end: a real zstd:chunked image produced by skopeo copy --dest-compress-format zstd:chunked fails to pull at the parent commit with exactly the production error, and pulls clean with this change.

Generated-by: AI
I diagnosed the failure on my own sealed composefs host and root-caused it to this decoder. I reviewed every line; the tests are mutation-checked.

zstd:chunked layers are multi-frame zstd streams (one frame per chunk,
plus skippable framing metadata), and gzip layers may legally contain
multiple members. The decoders stop at the first frame or member
boundary, so such layers import a truncated tar and fail with
"unexpected EOF reading tar entry" (bootc-dev/bootc#2408).

Enable multiple_members(true) on both decoders so the full stream
decodes; libzstd skips skippable frames natively at any position. This
is full-pull correctness only: nothing exploits the chunked TOC for
partial pulls, which remains composefs#137.

Raise the async-compression floor to 0.4.43, which fixed an infinite
loop on a corrupt later frame with multiple_members enabled
(Nullus157/async-compression#470); older versions could hang on a
malformed blob once the flag is on.

Generated-by: AI
Signed-off-by: Andrew Dunn <andrew@dunn.dev>

@cgwalters cgwalters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thank you!

),
MediaType::ImageLayerGzip | MediaType::ImageLayerNonDistributableGzip => {
let mut decoder = GzipDecoder::new(buf);
// Gzip layers may concatenate multiple members; keep decoding past

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@andrewdunndev

Copy link
Copy Markdown
Contributor Author

The bls/rhel9 example failure looks environmental rather than related to this change: test_basic's reboot drops with an ssh-over-vsock broken pipe on both attempts, the same job is green on current main, and the kernel-core posttrans warning in its log appears in green runs as well. No way to re-trigger from a fork.

andrewdunndev added a commit to andrewdunndev/composefs-rs that referenced this pull request Aug 25, 2026
zstd:chunked layers are inherently multi-frame (one zstd frame per
chunk, plus skippable TOC framing), so the decode fix ("oci: Decode
multi-frame compressed layers") was exactly the case this format
needed, but it shipped with no zstd:chunked-specific coverage. cgwalters flagged this gap on composefs#381 ("we are definitely
missing integration tests w/ zstd:chunked"), tracked as composefs#383.

Adds test_zstd_chunked_pull: builds the same deterministic OCI layout
tests::cli::test_oci_pull_and_inspect uses, recompresses it to
zstd:chunked with skopeo (--dest-force-compress-format, so the result
can't silently stay gzip), asserts the produced manifest carries the
zstd-chunked TOC annotation (so the fixture can't silently degrade to
plain zstd either), pulls it through the same cfsctl oci pull oci:
path the other OCI-layout tests use, and asserts the resulting
composefs image ID is byte-identical to OCI_LAYOUT_COMPOSEFS_ID, the
same content pulled as plain gzip. Before the multi-frame fix this
would either fail the import or reproduce a different ID, since the
decoder stopped at the first frame boundary.

Gated on skopeo being present (mirrors tests::old_format's
have_skopeo()); also skips gracefully if the installed skopeo's copy
rejects --dest-compress-format zstd:chunked.

Generated-by: AI
Signed-off-by: Andrew Dunn <andrew@dunn.dev>
@cgwalters
cgwalters added this pull request to the merge queue Aug 25, 2026
Merged via the queue into composefs:main with commit a8fe09d Aug 25, 2026
33 of 35 checks passed
Johan-Liebert1 pushed a commit to Johan-Liebert1/composefs-rs that referenced this pull request Aug 31, 2026
zstd:chunked layers are inherently multi-frame (one zstd frame per
chunk, plus skippable TOC framing), so the decode fix ("oci: Decode
multi-frame compressed layers") was exactly the case this format
needed, but it shipped with no zstd:chunked-specific coverage. cgwalters flagged this gap on composefs#381 ("we are definitely
missing integration tests w/ zstd:chunked"), tracked as composefs#383.

Adds test_zstd_chunked_pull: builds the same deterministic OCI layout
tests::cli::test_oci_pull_and_inspect uses, recompresses it to
zstd:chunked with skopeo (--dest-force-compress-format, so the result
can't silently stay gzip), asserts the produced manifest carries the
zstd-chunked TOC annotation (so the fixture can't silently degrade to
plain zstd either), pulls it through the same cfsctl oci pull oci:
path the other OCI-layout tests use, and asserts the resulting
composefs image ID is byte-identical to OCI_LAYOUT_COMPOSEFS_ID, the
same content pulled as plain gzip. Before the multi-frame fix this
would either fail the import or reproduce a different ID, since the
decoder stopped at the first frame boundary.

Gated on skopeo being present (mirrors tests::old_format's
have_skopeo()); also skips gracefully if the installed skopeo's copy
rejects --dest-compress-format zstd:chunked.

Generated-by: AI
Signed-off-by: Andrew Dunn <andrew@dunn.dev>
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.

2 participants