Skip to content

Detect layer compression by content, not media type#405

Open
mattpollock wants to merge 1 commit into
concourse:masterfrom
mitre-public:fix/sniff-layer-compression
Open

Detect layer compression by content, not media type#405
mattpollock wants to merge 1 commit into
concourse:masterfrom
mitre-public:fix/sniff-layer-compression

Conversation

@mattpollock

@mattpollock mattpollock commented Jun 24, 2026

Copy link
Copy Markdown

What

extractLayer chose the decompressor from the layer's declared media type: only types.OCILayerZStd was treated as zstd, and everything else fell through to gzip. That breaks for zstd layers labeled with a non-OCI media type — most commonly BuildKit pushing with Docker schema2, which emits application/vnd.docker.image.rootfs.diff.tar.zstd. The media-type switch routes those to gzip.NewReader, so in fails with:

write rootfs: extract image: gzip: invalid header

This is increasingly common as people adopt zstd-layer base images (e.g. hardened/minimal bases) and push them through standard docker/build-push-action without forcing OCI media types.

Fix

Detect the compression from the layer's own leading magic bytes instead of trusting the media-type label:

  • zstd -> 28 b5 2f fd
  • gzip -> 1f 8b
  • otherwise -> read as an uncompressed tar

So gzip, zstd, and uncompressed layers all unpack regardless of whether they're labeled OCI or Docker media types. mediaType is kept in the signature for compatibility but no longer drives the decision.

Repro

# zstd layers under Docker schema2 media types (oci-mediatypes=false)
docker buildx build --output type=image,name=$IMG,push=true,compression=zstd,force-compression=true,oci-mediatypes=false .

get of $IMG fails with gzip: invalid header before this change; succeeds after.

Notes

  • No behavior change for existing gzip / OCI-zstd images (same decompressors, just selected by content).
  • Single file (commands/rootfs.go). Happy to add a regression test fixture (a Docker-schema2 zstd image alongside the existing OCI-zstd one in testdata/setup-images) if you'd like it in this PR.

extractLayer routed decompression off the layer's declared media type:
only types.OCILayerZStd was treated as zstd, everything else defaulted to
gzip. But layers can be zstd-compressed while labeled with a non-OCI media
type -- e.g. BuildKit pushing with Docker schema2 emits
application/vnd.docker.image.rootfs.diff.tar.zstd -- which the switch
mis-routes to gzip and fails with 'gzip: invalid header'.

Sniff the layer's leading magic bytes instead, so gzip, zstd, and
uncompressed tar all unpack regardless of how the layer is labeled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@linux-foundation-easycla

Copy link
Copy Markdown

CLA Not Signed

@taylorsilva taylorsilva left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the PR! This LGTM, just make one final clean up of the func signature and then I'll merge. How annoying is it to have standards and then NOT have them followed 😭

P.S. Can you complete the EasyCLA too, please and TY 🙏

Comment thread commands/rootfs.go
Comment on lines +214 to 216
// uncompressed tar regardless of how the layer is labeled. mediaType is kept
// for signature compatibility but no longer drives the decision.
func compressionReader(layer io.ReadCloser, mediaType types.MediaType, bar *mpb.Bar) (*tar.Reader, compressReaderClose, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This func is private and only used in one place. Let's just update the signature and update where it's called, which should be higher up in this file.

Suggested change
// uncompressed tar regardless of how the layer is labeled. mediaType is kept
// for signature compatibility but no longer drives the decision.
func compressionReader(layer io.ReadCloser, mediaType types.MediaType, bar *mpb.Bar) (*tar.Reader, compressReaderClose, error) {
// uncompressed tar regardless of how the layer is labeled. mediaType is kept
// for signature compatibility but no longer drives the decision.
func compressionReader(layer io.ReadCloser, bar *mpb.Bar) (*tar.Reader, compressReaderClose, error) {

And update here:

mediaType, err := layer.MediaType()
if err != nil {
return err
}
tr, crc, err := compressionReader(lyr, mediaType, bar)
if err != nil {
return err
}

Comment thread commands/rootfs.go

type compressReaderClose func() error

// Layer compression magic numbers.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: for easy lookup reference in the future link to https://en.wikipedia.org/wiki/List_of_file_signatures

Suggested change
// Layer compression magic numbers.
// Layer compression magic numbers. Can find more at:
// https://en.wikipedia.org/wiki/List_of_file_signatures

@taylorsilva taylorsilva moved this to Waiting on Contributor in Pull Requests Jun 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Waiting on Contributor

Development

Successfully merging this pull request may close these issues.

2 participants