Skip to content

fix(mutate): preserve config blob and layers for non-Docker OCI artifacts#2286

Merged
Subserial merged 9 commits into
google:mainfrom
blackwell-systems:fix-mutate-oci-artifacts
May 15, 2026
Merged

fix(mutate): preserve config blob and layers for non-Docker OCI artifacts#2286
Subserial merged 9 commits into
google:mainfrom
blackwell-systems:fix-mutate-oci-artifacts

Conversation

@blackwell-systems

Copy link
Copy Markdown
Contributor

Summary

compute() in image.go unconditionally parses the config as a Docker image config and derives layers from RootFS.DiffIDs. For non-Docker OCI artifacts (Helm charts, WASM modules, arbitrary blobs), the config has no RootFS.DiffIDs, causing:

  1. Layers() returns empty. remote.Write uses Layers() to determine which blobs to upload. On a cross-repository push, layer blobs are never uploaded, and the registry rejects the manifest with MANIFEST_BLOB_UNKNOWN.

  2. The config blob digest is corrupted. RawConfigFile() re-marshals the config through the Docker ConfigFile struct, producing a different digest than the original. The manifest references a blob that was never uploaded.

Repro: crane copy registry-1.docker.io/bitnamicharts/nginx:13.2.14 <dest> (Helm chart with application/vnd.cncf.helm.config.v1+json config).

Fix

Detect non-image configs by checking the config media type against the two known image config types (DockerConfigJSON, OCIConfigJSON). When the config is neither:

  • computeArtifact(): new method that preserves the original config blob (no re-marshal, no digest corruption) and builds the layer list from manifest descriptors and added layers.
  • Layers(): falls back to base image layers + added layers instead of deriving from empty DiffIDs.
  • RawConfigFile(): returns the original raw bytes from the base image instead of re-marshaling.

Standard Docker/OCI image workflows are unchanged (all existing tests pass).

Testing

Added TestNonImageArtifactPreservesConfig which creates an artifact with application/vnd.cncf.helm.config.v1+json config type, mutates annotations, and verifies:

  • Layers() does not error
  • Config media type is preserved in the manifest
  • Config blob digest matches the raw config content (no corruption)
  • Annotations are present

Full ./pkg/... test suite passes across all packages.

Fixes #2251

…acts

compute() unconditionally parses the config as a Docker image config
and derives layers from RootFS.DiffIDs. For non-Docker OCI artifacts
(Helm charts, WASM modules), the config has no DiffIDs, causing
Layers() to return empty (remote.Write skips blob uploads) and
RawConfigFile() to corrupt the config digest via re-marshaling.

Detect non-image configs by checking the config media type. When it
is not DockerConfigJSON or OCIConfigJSON, delegate to computeArtifact
which preserves the original config blob and derives layers from the
base image and manifest descriptors.

Fixes google#2251

Signed-off-by: Dayna Blackwell <dayna@blackwell-systems.com>
Comment thread pkg/v1/mutate/mutate_test.go Outdated
@codecov-commenter

codecov-commenter commented May 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.22222% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 56.89%. Comparing base (e5983f2) to head (bf03656).

Files with missing lines Patch % Lines
pkg/v1/mutate/image.go 82.22% 4 Missing and 4 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2286      +/-   ##
==========================================
+ Coverage   56.83%   56.89%   +0.05%     
==========================================
  Files         165      165              
  Lines       11319    11334      +15     
==========================================
+ Hits         6433     6448      +15     
  Misses       4121     4121              
  Partials      765      765              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Subserial

Copy link
Copy Markdown
Contributor

Confirmed with the image in the original bug that this prevents corrupting the image on upload and download. I can approve this change after comments and linter errors are addressed.

Comment thread pkg/v1/mutate/image.go Outdated

@Subserial Subserial left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry for noting this just now, but this code is very poor quality and duplicates several code paths that already exist. I've left comments, but from now on please review your generated changes to reduce this introduction of code debt.

Comment thread pkg/v1/mutate/image.go
Comment thread pkg/v1/mutate/image.go Outdated
Comment on lines +284 to +298
// For non-image OCI artifacts, RootFS.DiffIDs is empty so partial.DiffIDs
// returns nothing. Fall back to the base image's layers plus any added layers.
if i.manifest != nil && !isImageConfig(i.manifest.Config.MediaType) {
layers, err := i.base.Layers()
if err != nil {
return nil, err
}
for _, add := range i.adds {
if add.Layer != nil {
layers = append(layers, add.Layer)
}
}
return layers, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same comment here. This logic already exists and is duplicated. Please reduce the footprint of this change.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

To fulfill this, you can remove this block and change the above if statement to:

if err := i.compute(); errors.Is(err, stream.ErrNotComputed) || (i.manifest != nil && !isImageConfig(i.manifest.Config.MediaType) {

Comment thread pkg/v1/mutate/image.go Outdated
Per review feedback:
- Inline artifact-specific behavior as conditional blocks within
  compute() instead of a separate computeArtifact() function
- Share manifest layer loop, annotations, mediaType, and subject
  assignment between both paths
- Trim isImageConfig comment
- Layers() artifact fallback stays inline (no new abstractions)

Net -70 lines.
@blackwell-systems

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed feedback. I've refactored to inline the artifact logic into compute() as suggested. The duplicated paths are eliminated and net change is -70 lines. Branch is up to date with main.

blackwell-systems added a commit to blackwell-systems/blog that referenced this pull request May 13, 2026

@Subserial Subserial left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Some of the comments seem to refer to images vs artifacts, which should be updated to images vs non-image artifacts.

Also, please do not remove arbitrary whitespace and comments from untouched code.

Comment thread pkg/v1/mutate/image.go Outdated
Comment on lines +284 to +298
// For non-image OCI artifacts, RootFS.DiffIDs is empty so partial.DiffIDs
// returns nothing. Fall back to the base image's layers plus any added layers.
if i.manifest != nil && !isImageConfig(i.manifest.Config.MediaType) {
layers, err := i.base.Layers()
if err != nil {
return nil, err
}
for _, add := range i.adds {
if add.Layer != nil {
layers = append(layers, add.Layer)
}
}
return layers, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

To fulfill this, you can remove this block and change the above if statement to:

if err := i.compute(); errors.Is(err, stream.ErrNotComputed) || (i.manifest != nil && !isImageConfig(i.manifest.Config.MediaType) {

Comment thread pkg/v1/mutate/image.go Outdated
blackwell-systems and others added 2 commits May 14, 2026 09:37
Address review feedback:
- Remove comment at line 124
- Merge the !isImageConfig block into the ErrNotComputed condition

@Subserial Subserial left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I was being a bit picky on the wording. LGTM, thanks for responding to the feedback on this PR.

@Subserial Subserial merged commit 35b354b into google:main May 15, 2026
17 checks passed
@EronWright

Copy link
Copy Markdown

Thanks @Subserial and @blackwell-systems for the fix!

Subserial pushed a commit to Subserial/go-containerregistry that referenced this pull request May 15, 2026
…acts (google#2286)

* fix(mutate): preserve config blob and layers for non-Docker OCI artifacts

compute() unconditionally parses the config as a Docker image config
and derives layers from RootFS.DiffIDs. For non-Docker OCI artifacts
(Helm charts, WASM modules), the config has no DiffIDs, causing
Layers() to return empty (remote.Write skips blob uploads) and
RawConfigFile() to corrupt the config digest via re-marshaling.

Detect non-image configs by checking the config media type. When it
is not DockerConfigJSON or OCIConfigJSON, delegate to computeArtifact
which preserves the original config blob and derives layers from the
base image and manifest descriptors.

Fixes google#2251

Signed-off-by: Dayna Blackwell <dayna@blackwell-systems.com>

* test: replace no-op _ = layers with assertion per review

* fix: rename unused originalManifest parameter to _ (revive lint)

* fix: remove unused originalManifest parameter from computeArtifact per review

* refactor: inline artifact logic into compute(), remove computeArtifact

Per review feedback:
- Inline artifact-specific behavior as conditional blocks within
  compute() instead of a separate computeArtifact() function
- Share manifest layer loop, annotations, mediaType, and subject
  assignment between both paths
- Trim isImageConfig comment
- Layers() artifact fallback stays inline (no new abstractions)

Net -70 lines.

* Combine Layers() artifact check into compute error branch

Address review feedback:
- Remove comment at line 124
- Merge the !isImageConfig block into the ErrNotComputed condition

---------

Signed-off-by: Dayna Blackwell <dayna@blackwell-systems.com>
@jabrown85 jabrown85 mentioned this pull request Jun 8, 2026
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.

mutate image wrappers assume Docker container image semantics, breaking non-Docker OCI artifacts

4 participants