oci: Decode multi-frame compressed layers - #381
Merged
cgwalters merged 1 commit intoAug 25, 2026
Conversation
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
reviewed
Aug 24, 2026
| ), | ||
| MediaType::ImageLayerGzip | MediaType::ImageLayerNonDistributableGzip => { | ||
| let mut decoder = GzipDecoder::new(buf); | ||
| // Gzip layers may concatenate multiple members; keep decoding past |
Collaborator
There was a problem hiding this comment.
Hmm yes, I guess in practice that happens with https://github.com/containerd/stargz-snapshotter/blob/main/docs/estargz.md
cgwalters
enabled auto-merge
August 24, 2026 16:55
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>
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>
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.
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 withmultiple_membersenabled (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
Decompressorroutes 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:chunkedfails 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.