From 24e3c987eae8a21c6104f4d81a09a6f24e99b3d3 Mon Sep 17 00:00:00 2001 From: Giuseppe Scrivano Date: Thu, 5 Mar 2026 12:16:46 +0000 Subject: [PATCH 1/4] chunked: add zstd:chunked sentinel constants and SkipMitigation function Add sentinel layer constants (ZstdChunkedSentinelContent and ZstdChunkedSentinelDigest) to the toc package and introduce SkipMitigation() to disable full-digest verification when a zstd:chunked sentinel layer is detected. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Giuseppe Scrivano --- storage/pkg/chunked/storage_linux.go | 21 +++++++++++++- storage/pkg/chunked/storage_unsupported.go | 6 ++++ storage/pkg/chunked/toc/toc.go | 32 ++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/storage/pkg/chunked/storage_linux.go b/storage/pkg/chunked/storage_linux.go index 2e6180d4ab..a09bc10707 100644 --- a/storage/pkg/chunked/storage_linux.go +++ b/storage/pkg/chunked/storage_linux.go @@ -72,6 +72,9 @@ type chunkedDiffer struct { // is no TOC referenced by the manifest. blobDigest digest.Digest blobSize int64 + // skipMitigation is set by SkipMitigation to skip the full-digest + // verification without using the insecure flag. + skipMitigation bool // Input format // ========== @@ -205,6 +208,22 @@ func (c *chunkedDiffer) Close() error { // If it returns an error that matches ErrFallbackToOrdinaryLayerDownload, the caller can // retry the operation with a different method. // The caller must call Close() on the returned Differ. + +// SkipMitigation configures the differ to skip the full-digest mitigation. +// Must be called after NewDiffer and before PrepareStagedLayer. +func SkipMitigation(d graphdriver.Differ) error { + cd, ok := d.(*chunkedDiffer) + if !ok { + return fmt.Errorf("SkipMitigation called with unexpected type %T", d) + } + if cd.convertToZstdChunked { + return fmt.Errorf("SkipMitigation called on a convert-from-raw differ for blob %s", cd.blobDigest) + } + logrus.Debugf("SkipMitigation: skipping full-digest verification for blob %s", cd.blobDigest) + cd.skipMitigation = true + return nil +} + func NewDiffer(ctx context.Context, store storage.Store, blobDigest digest.Digest, blobSize int64, annotations map[string]string, iss ImageSourceSeekable) (graphdriver.Differ, error) { pullOptions := parsePullOptions(store) @@ -1868,7 +1887,7 @@ func (c *chunkedDiffer) ApplyDiff(dest string, options *archive.TarOptions, diff // via insecureAllowUnpredictableImageContents . if output.UncompressedDigest == "" { switch { - case c.pullOptions.insecureAllowUnpredictableImageContents: + case c.pullOptions.insecureAllowUnpredictableImageContents || c.skipMitigation: // Oh well. Skip the costly digest computation. case output.TarSplit != nil: if _, err := output.TarSplit.Seek(0, io.SeekStart); err != nil { diff --git a/storage/pkg/chunked/storage_unsupported.go b/storage/pkg/chunked/storage_unsupported.go index 7aa313c4af..951f06a8d5 100644 --- a/storage/pkg/chunked/storage_unsupported.go +++ b/storage/pkg/chunked/storage_unsupported.go @@ -5,12 +5,18 @@ package chunked import ( "context" "errors" + "fmt" digest "github.com/opencontainers/go-digest" storage "go.podman.io/storage" graphdriver "go.podman.io/storage/drivers" ) +// SkipMitigation is not supported on non-Linux platforms. +func SkipMitigation(d graphdriver.Differ) error { + return fmt.Errorf("SkipMitigation is not supported on this platform") +} + // NewDiffer returns a differ than can be used with [Store.PrepareStagedLayer]. // The caller must call Close() on the returned Differ. func NewDiffer(ctx context.Context, store storage.Store, blobDigest digest.Digest, blobSize int64, annotations map[string]string, iss ImageSourceSeekable) (graphdriver.Differ, error) { diff --git a/storage/pkg/chunked/toc/toc.go b/storage/pkg/chunked/toc/toc.go index fafa40e607..3a22b99bf5 100644 --- a/storage/pkg/chunked/toc/toc.go +++ b/storage/pkg/chunked/toc/toc.go @@ -27,6 +27,38 @@ var ChunkedAnnotations = map[string]struct{}{ // Duplicate it here to avoid a dependency on the package. const tocJSONDigestAnnotation = "containerd.io/snapshot/stargz/toc.digest" +// ZstdChunkedSentinelContent is the well-known content of the sentinel layer +// used to signal that a manifest's zstd:chunked layers can be used without +// the full-digest mitigation. +// +// It is a 512-byte block (tar header size) that is definitively not valid tar: +// - Byte 0 is \x00 (marks as binary/non-text) +// - Bytes 148-155 (checksum field) are all \xff (invalid for any tar variant) +// - Bytes 257-262 (magic field) are "NOTTAR" instead of "ustar\0" +// +// This ensures that no tar implementation can accidentally parse this as a +// valid archive entry. +var ZstdChunkedSentinelContent = func() []byte { + var block [512]byte + // Name field (bytes 0-99): binary zero followed by identifier + copy(block[1:], "ZSTD-CHUNKED-SENTINEL") + // Checksum field (bytes 148-155): all 0xff — invalid for v7 and ustar + for i := 148; i < 156; i++ { + block[i] = 0xff + } + // Magic field (bytes 257-262): "NOTTAR" — not "ustar\0" + copy(block[257:], "NOTTAR") + // Version + message area (bytes 263-511): + msg := "This image must be consumed using the TOC digests " + + "and NEVER as a tar archive.\n" + + "See: https://github.com/containers/image" + copy(block[263:], msg) + return block[:] +}() + +// ZstdChunkedSentinelDigest is the digest of ZstdChunkedSentinelContent. +var ZstdChunkedSentinelDigest = digest.FromBytes(ZstdChunkedSentinelContent) + // GetTOCDigest returns the digest of the TOC as recorded in the annotations. // This function retrieves a digest that represents the content of a // table of contents (TOC) from the image's annotations. From 3928578ac55aa311846081807878746f7d865a88 Mon Sep 17 00:00:00 2001 From: Giuseppe Scrivano Date: Thu, 5 Mar 2026 12:16:56 +0000 Subject: [PATCH 2/4] manifest: add zstd:chunked sentinel annotation support and instance selection Add annotation constants for identifying zstd:chunked sentinel layers in OCI manifests. Extend instance candidate selection to prefer manifests with sentinel annotations when available. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Giuseppe Scrivano --- image/internal/manifest/oci_index.go | 45 +++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/image/internal/manifest/oci_index.go b/image/internal/manifest/oci_index.go index 4e4060255a..02a53e69cc 100644 --- a/image/internal/manifest/oci_index.go +++ b/image/internal/manifest/oci_index.go @@ -12,6 +12,7 @@ import ( "github.com/opencontainers/go-digest" imgspec "github.com/opencontainers/image-spec/specs-go" imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/sirupsen/logrus" platform "go.podman.io/image/v5/internal/pkg/platform" compression "go.podman.io/image/v5/pkg/compression/types" "go.podman.io/image/v5/types" @@ -26,6 +27,12 @@ const ( // use gzip, depending on their local policy. OCI1InstanceAnnotationCompressionZSTD = "io.github.containers.compression.zstd" OCI1InstanceAnnotationCompressionZSTDValue = "true" + + // OCI1InstanceAnnotationZstdChunkedSentinel is an annotation name that can be placed on a manifest descriptor + // in an OCI index. The value must be "true". It signals that the manifest contains a sentinel layer + // prepended to its layers, indicating that aware clients can skip the zstd:chunked full-digest mitigation. + OCI1InstanceAnnotationZstdChunkedSentinel = "io.github.containers.zstd-chunked.sentinel" + OCI1InstanceAnnotationZstdChunkedSentinelValue = "true" ) // OCI1IndexPublic is just an alias for the OCI index type, but one which we can @@ -187,16 +194,14 @@ func (index *OCI1IndexPublic) editInstances(editInstances []ListEdit, cannotModi } if len(addedEntries) != 0 || updatedAnnotations { slices.SortStableFunc(index.Manifests, func(a, b imgspecv1.Descriptor) int { - // FIXME? With Go 1.21 and cmp.Compare available, turn instanceIsZstd into an integer score that can be compared, and generalizes - // into more algorithms? - aZstd := instanceIsZstd(a) - bZstd := instanceIsZstd(b) + aScore := instanceSortScore(a) + bScore := instanceSortScore(b) switch { - case aZstd == bZstd: + case aScore == bScore: return 0 - case !aZstd: // Implies bZstd + case aScore < bScore: return -1 - default: // aZstd && !bZstd + default: return 1 } }) @@ -218,9 +223,28 @@ func instanceIsZstd(manifest imgspecv1.Descriptor) bool { return false } +// instanceHasSentinel returns true if instance has the zstd:chunked sentinel annotation. +func instanceHasSentinel(manifest imgspecv1.Descriptor) bool { + return manifest.Annotations[OCI1InstanceAnnotationZstdChunkedSentinel] == OCI1InstanceAnnotationZstdChunkedSentinelValue +} + +// instanceSortScore returns a sorting score for manifest index ordering: +// 0 = plain (gzip), 1 = zstd, 2 = zstd:chunked sentinel. +// Lower scores sort first in the index. +func instanceSortScore(d imgspecv1.Descriptor) int { + if instanceHasSentinel(d) { + return 2 + } + if instanceIsZstd(d) { + return 1 + } + return 0 +} + type instanceCandidate struct { platformIndex int // Index of the candidate in platform.WantedPlatforms: lower numbers are preferred; or math.maxInt if the candidate doesn’t have a platform isZstd bool // tells if particular instance if zstd instance + hasSentinel bool // tells if particular instance has the zstd:chunked sentinel layer manifestPosition int // A zero-based index of the instance in the manifest list digest digest.Digest // Instance digest } @@ -235,6 +259,8 @@ func (ic instanceCandidate) isPreferredOver(other *instanceCandidate, preferGzip } else { return !ic.isZstd } + case ic.hasSentinel != other.hasSentinel: + return ic.hasSentinel case ic.manifestPosition != other.manifestPosition: return ic.manifestPosition < other.manifestPosition } @@ -248,7 +274,7 @@ func (index *OCI1IndexPublic) chooseInstance(ctx *types.SystemContext, preferGzi var bestMatch *instanceCandidate bestMatch = nil for manifestIndex, d := range index.Manifests { - candidate := instanceCandidate{platformIndex: math.MaxInt, manifestPosition: manifestIndex, isZstd: instanceIsZstd(d), digest: d.Digest} + candidate := instanceCandidate{platformIndex: math.MaxInt, manifestPosition: manifestIndex, isZstd: instanceIsZstd(d), hasSentinel: instanceHasSentinel(d), digest: d.Digest} if d.Platform != nil { imagePlatform := ociPlatformClone(*d.Platform) platformIndex := slices.IndexFunc(wantedPlatforms, func(wantedPlatform imgspecv1.Platform) bool { @@ -264,6 +290,9 @@ func (index *OCI1IndexPublic) chooseInstance(ctx *types.SystemContext, preferGzi } } if bestMatch != nil { + if bestMatch.hasSentinel { + logrus.Debugf("Selected instance %s with zstd:chunked sentinel (position %d in index)", bestMatch.digest, bestMatch.manifestPosition) + } return bestMatch.digest, nil } return "", fmt.Errorf("no image found in image index for architecture %q, variant %q, OS %q", wantedPlatforms[0].Architecture, wantedPlatforms[0].Variant, wantedPlatforms[0].OS) From 6fb485dcb67ca8ebfdd63e2585927ab97859a58b Mon Sep 17 00:00:00 2001 From: Giuseppe Scrivano Date: Thu, 5 Mar 2026 12:17:07 +0000 Subject: [PATCH 3/4] copy: detect zstd:chunked sentinel layer and skip full-digest mitigation during pull When pulling an image, the storage destination's FilterLayers method detects the zstd:chunked sentinel layer, marks it as empty, and internally records that SkipMitigation() should be called to bypass unnecessary full-digest verification. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Giuseppe Scrivano --- image/copy/single.go | 115 +++++++++++++++++++++++++++- image/copy/single_test.go | 138 ++++++++++++++++++++++++++++++++++ image/storage/storage_dest.go | 59 ++++++++++++--- image/storage/storage_src.go | 18 ++++- 4 files changed, 315 insertions(+), 15 deletions(-) diff --git a/image/copy/single.go b/image/copy/single.go index 2280e7f53a..0d203d1fa0 100644 --- a/image/copy/single.go +++ b/image/copy/single.go @@ -3,6 +3,7 @@ package copy import ( "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -42,6 +43,7 @@ type imageCopier struct { compressionFormat *compressiontypes.Algorithm // Compression algorithm to use, if the user explicitly requested one, or nil. compressionLevel *int requireCompressionFormatMatch bool + stripSentinel bool // Remove sentinel layer[0] and DiffID[0] from final manifest/config } type copySingleImageOptions struct { @@ -458,6 +460,35 @@ func (ic *imageCopier) copyLayers(ctx context.Context) ([]compressiontypes.Algor } manifestLayerInfos := man.LayerInfos() + // Let the destination filter manifest layer infos before copying. + // Destinations that support it (e.g., c/storage) may detect storage-specific + // markers and return indices of layers that should be skipped. + type layerFilter interface { + FilterLayers([]manifest.LayerInfo) []int + } + filteredLayers := set.New[int]() + if f, ok := ic.c.dest.(layerFilter); ok { + for _, idx := range f.FilterLayers(manifestLayerInfos) { + filteredLayers.Add(idx) + } + } + + // If the destination does not implement FilterLayers (non-storage), strip the + // sentinel layer when compression is changing away from zstd:chunked. + if filteredLayers.Empty() && len(manifestLayerInfos) > 0 && manifestLayerInfos[0].Digest == chunkedToc.ZstdChunkedSentinelDigest { + destFormat := ic.compressionFormat + if destFormat == nil { + destFormat = defaultCompressionFormat + } + if destFormat.Name() != compressiontypes.ZstdChunkedAlgorithmName { + if ic.cannotModifyManifestReason != "" { + return nil, fmt.Errorf("copying this image requires stripping the zstd:chunked sentinel layer, which we cannot do: %q", ic.cannotModifyManifestReason) + } + filteredLayers.Add(0) + ic.stripSentinel = true + } + } + // copyGroup is used to determine if all layers are copied copyGroup := sync.WaitGroup{} @@ -466,7 +497,10 @@ func (ic *imageCopier) copyLayers(ctx context.Context) ([]compressiontypes.Algor defer ic.c.concurrentBlobCopiesSemaphore.Release(1) defer copyGroup.Done() cld := copyLayerData{} - if !ic.c.options.DownloadForeignLayers && ic.c.dest.AcceptsForeignLayerURLs() && len(srcLayer.URLs) != 0 { + if manifestLayerInfos[index].EmptyLayer || filteredLayers.Contains(index) { + cld.destInfo = srcLayer + logrus.Debugf("Skipping layer %q", srcLayer.Digest) + } else if !ic.c.options.DownloadForeignLayers && ic.c.dest.AcceptsForeignLayerURLs() && len(srcLayer.URLs) != 0 { // DiffIDs are, currently, needed only when converting from schema1. // In which case src.LayerInfos will not have URLs because schema1 // does not support them. @@ -592,8 +626,20 @@ func (ic *imageCopier) copyUpdatedConfigAndManifest(ctx context.Context, instanc return nil, "", fmt.Errorf("reading manifest: %w", err) } - if err := ic.copyConfig(ctx, pendingImage); err != nil { - return nil, "", err + configBlob, err := pendingImage.ConfigBlob(ctx) + if err != nil { + return nil, "", fmt.Errorf("reading config blob: %w", err) + } + + if ic.stripSentinel { + man, configBlob, err = ic.stripSentinelFromManifestAndConfig(ctx, man, configBlob) + if err != nil { + return nil, "", err + } + } else { + if err := ic.copyConfig(ctx, pendingImage); err != nil { + return nil, "", err + } } ic.c.Printf("Writing manifest to image destination\n") @@ -611,6 +657,69 @@ func (ic *imageCopier) copyUpdatedConfigAndManifest(ctx context.Context, instanc return man, manifestDigest, nil } +// stripSentinelFromManifestAndConfig removes the sentinel layer (layer[0]) from +// the OCI manifest and the corresponding DiffID (DiffIDs[0]) from the config. +// It pushes the new config to the destination and returns the updated manifest +// and config blobs. +func (ic *imageCopier) stripSentinelFromManifestAndConfig(ctx context.Context, manBlob []byte, configBlob []byte) ([]byte, []byte, error) { + ociMan, err := manifest.OCI1FromManifest(manBlob) + if err != nil { + return nil, nil, fmt.Errorf("parsing OCI manifest for sentinel stripping: %w", err) + } + if len(ociMan.Layers) == 0 || ociMan.Layers[0].Digest != chunkedToc.ZstdChunkedSentinelDigest { + return nil, nil, errors.New("internal error: stripSentinel set but manifest layer[0] is not the sentinel") + } + ociMan.Layers = ociMan.Layers[1:] + + var ociConfig imgspecv1.Image + if err := json.Unmarshal(configBlob, &ociConfig); err != nil { + return nil, nil, fmt.Errorf("parsing config for sentinel stripping: %w", err) + } + if len(ociConfig.RootFS.DiffIDs) == 0 || ociConfig.RootFS.DiffIDs[0] != chunkedToc.ZstdChunkedSentinelDigest { + return nil, nil, errors.New("internal error: stripSentinel set but config DiffIDs[0] is not the sentinel") + } + ociConfig.RootFS.DiffIDs = ociConfig.RootFS.DiffIDs[1:] + + newConfigBlob, err := json.Marshal(ociConfig) + if err != nil { + return nil, nil, fmt.Errorf("marshaling config after sentinel stripping: %w", err) + } + configDigest := digest.FromBytes(newConfigBlob) + + // Push new config to destination. + configBlobInfo := types.BlobInfo{ + Digest: configDigest, + Size: int64(len(newConfigBlob)), + MediaType: imgspecv1.MediaTypeImageConfig, + } + reused, _, err := ic.c.dest.TryReusingBlobWithOptions(ctx, configBlobInfo, + private.TryReusingBlobOptions{Cache: ic.c.blobInfoCache}) + if err != nil { + return nil, nil, fmt.Errorf("checking stripped config blob: %w", err) + } + if !reused { + _, err = ic.c.dest.PutBlobWithOptions(ctx, bytes.NewReader(newConfigBlob), + configBlobInfo, private.PutBlobOptions{Cache: ic.c.blobInfoCache, IsConfig: true}) + if err != nil { + return nil, nil, fmt.Errorf("pushing stripped config blob: %w", err) + } + } + + // Update manifest to point to the new config. + ociMan.Config = imgspecv1.Descriptor{ + MediaType: imgspecv1.MediaTypeImageConfig, + Digest: configDigest, + Size: int64(len(newConfigBlob)), + } + newManBlob, err := ociMan.Serialize() + if err != nil { + return nil, nil, fmt.Errorf("serializing manifest after sentinel stripping: %w", err) + } + + logrus.Debugf("Stripped zstd:chunked sentinel layer from manifest and config") + return newManBlob, newConfigBlob, nil +} + // copyConfig copies config.json, if any, from src to dest. func (ic *imageCopier) copyConfig(ctx context.Context, src types.Image) error { srcInfo := src.ConfigInfo() diff --git a/image/copy/single_test.go b/image/copy/single_test.go index 66daf901ce..e814d9117c 100644 --- a/image/copy/single_test.go +++ b/image/copy/single_test.go @@ -2,21 +2,29 @@ package copy import ( "bytes" + "context" + "encoding/json" "errors" "fmt" "io" "os" + "path/filepath" "testing" "time" digest "github.com/opencontainers/go-digest" + "github.com/opencontainers/image-spec/specs-go" imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.podman.io/image/v5/internal/private" + "go.podman.io/image/v5/manifest" + ociLayout "go.podman.io/image/v5/oci/layout" "go.podman.io/image/v5/pkg/compression" compressiontypes "go.podman.io/image/v5/pkg/compression/types" + "go.podman.io/image/v5/signature" "go.podman.io/image/v5/types" + chunkedToc "go.podman.io/storage/pkg/chunked/toc" ) func TestUpdatedBlobInfoFromReuse(t *testing.T) { @@ -159,3 +167,133 @@ func TestComputeDiffID(t *testing.T) { _, err = computeDiffID(reader, nil) assert.Error(t, err) } + +// createOCILayoutWithSentinel creates a minimal OCI layout directory containing +// a single image with a sentinel layer prepended (simulating a zstd:chunked +// sentinel image). Returns the path to the OCI layout dir. +func createOCILayoutWithSentinel(t *testing.T, dir string) { + t.Helper() + + blobsDir := filepath.Join(dir, "blobs", "sha256") + require.NoError(t, os.MkdirAll(blobsDir, 0o755)) + + // Write oci-layout + ociLayoutJSON, err := json.Marshal(imgspecv1.ImageLayout{Version: specs.Version}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "oci-layout"), ociLayoutJSON, 0o644)) + + // Create a real layer (a minimal gzip stream). + realLayerContent := []byte("real layer data for testing") + realLayerDigest := digest.FromBytes(realLayerContent) + require.NoError(t, os.WriteFile(filepath.Join(blobsDir, realLayerDigest.Encoded()), realLayerContent, 0o644)) + + // Write sentinel blob. + sentinelContent := chunkedToc.ZstdChunkedSentinelContent + sentinelDigest := chunkedToc.ZstdChunkedSentinelDigest + require.NoError(t, os.WriteFile(filepath.Join(blobsDir, sentinelDigest.Encoded()), sentinelContent, 0o644)) + + // Create config with sentinel DiffID at [0]. + config := imgspecv1.Image{ + RootFS: imgspecv1.RootFS{ + Type: "layers", + DiffIDs: []digest.Digest{sentinelDigest, realLayerDigest}, + }, + } + configBlob, err := json.Marshal(config) + require.NoError(t, err) + configDigest := digest.FromBytes(configBlob) + require.NoError(t, os.WriteFile(filepath.Join(blobsDir, configDigest.Encoded()), configBlob, 0o644)) + + // Create OCI manifest with sentinel layer at [0]. + ociManifest := imgspecv1.Manifest{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: imgspecv1.MediaTypeImageManifest, + Config: imgspecv1.Descriptor{ + MediaType: imgspecv1.MediaTypeImageConfig, + Digest: configDigest, + Size: int64(len(configBlob)), + }, + Layers: []imgspecv1.Descriptor{ + { + MediaType: imgspecv1.MediaTypeImageLayerZstd, + Digest: sentinelDigest, + Size: int64(len(sentinelContent)), + }, + { + MediaType: imgspecv1.MediaTypeImageLayerZstd, + Digest: realLayerDigest, + Size: int64(len(realLayerContent)), + }, + }, + } + manifestBlob, err := json.Marshal(ociManifest) + require.NoError(t, err) + manifestDigest := digest.FromBytes(manifestBlob) + require.NoError(t, os.WriteFile(filepath.Join(blobsDir, manifestDigest.Encoded()), manifestBlob, 0o644)) + + // Create index.json. + index := imgspecv1.Index{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: imgspecv1.MediaTypeImageIndex, + Manifests: []imgspecv1.Descriptor{ + { + MediaType: imgspecv1.MediaTypeImageManifest, + Digest: manifestDigest, + Size: int64(len(manifestBlob)), + }, + }, + } + indexBlob, err := json.Marshal(index) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "index.json"), indexBlob, 0o644)) +} + +func TestStripSentinelOnCompressionChange(t *testing.T) { + srcDir := t.TempDir() + destDir := t.TempDir() + + createOCILayoutWithSentinel(t, srcDir) + + srcRef, err := ociLayout.ParseReference(srcDir) + require.NoError(t, err) + destRef, err := ociLayout.ParseReference(destDir) + require.NoError(t, err) + + policy := &signature.Policy{Default: signature.PolicyRequirements{signature.NewPRInsecureAcceptAnything()}} + pc, err := signature.NewPolicyContext(policy) + require.NoError(t, err) + defer func() { require.NoError(t, pc.Destroy()) }() + + ctx := context.Background() + gzipAlgo := compression.Gzip + copiedManifest, err := Image(ctx, pc, destRef, srcRef, &Options{ + DestinationCtx: &types.SystemContext{ + CompressionFormat: &gzipAlgo, + }, + }) + require.NoError(t, err) + + // Parse the resulting manifest and verify the sentinel layer is gone. + ociMan, err := manifest.OCI1FromManifest(copiedManifest) + require.NoError(t, err) + + for i, layer := range ociMan.Layers { + assert.NotEqual(t, chunkedToc.ZstdChunkedSentinelDigest, layer.Digest, + "sentinel layer should have been stripped, but found at index %d", i) + } + + // Read the config from the destination and verify sentinel DiffID is gone. + configBlob, err := os.ReadFile(filepath.Join(destDir, "blobs", "sha256", ociMan.Config.Digest.Encoded())) + require.NoError(t, err) + var ociConfig imgspecv1.Image + require.NoError(t, json.Unmarshal(configBlob, &ociConfig)) + + for i, diffID := range ociConfig.RootFS.DiffIDs { + assert.NotEqual(t, chunkedToc.ZstdChunkedSentinelDigest, diffID, + "sentinel DiffID should have been stripped, but found at index %d", i) + } + + // Should have exactly 1 layer (the real one, not the sentinel). + assert.Len(t, ociMan.Layers, 1, "expected exactly 1 layer after sentinel stripping") + assert.Len(t, ociConfig.RootFS.DiffIDs, 1, "expected exactly 1 DiffID after sentinel stripping") +} diff --git a/image/storage/storage_dest.go b/image/storage/storage_dest.go index 000be3ccd1..52aef4ed62 100644 --- a/image/storage/storage_dest.go +++ b/image/storage/storage_dest.go @@ -57,15 +57,20 @@ type storageImageDestination struct { stubs.AlwaysSupportsSignatures imageRef storageReference - directory string // Temporary directory where we store blobs until Commit() time - nextTempFileID atomic.Int32 // A counter that we use for computing filenames to assign to blobs - manifest []byte // (Per-instance) manifest contents, or nil if not yet known. - manifestMIMEType string // Valid if manifest != nil - manifestDigest digest.Digest // Valid if manifest != nil - untrustedDiffIDValues []digest.Digest // From config’s RootFS.DiffIDs (not even validated to be valid digest.Digest!); or nil if not read yet - signatures []byte // Signature contents, temporary - signatureses map[digest.Digest][]byte // Instance signature contents, temporary - metadata storageImageMetadata // Metadata contents being built + directory string // Temporary directory where we store blobs until Commit() time + nextTempFileID atomic.Int32 // A counter that we use for computing filenames to assign to blobs + manifest []byte // (Per-instance) manifest contents, or nil if not yet known. + manifestMIMEType string // Valid if manifest != nil + manifestDigest digest.Digest // Valid if manifest != nil + untrustedDiffIDValues []digest.Digest // From config’s RootFS.DiffIDs (not even validated to be valid digest.Digest!); or nil if not read yet + // filteredLayerIndices are layer indices marked empty by FilterLayers. + // When set, the full-digest mitigation is skipped for remaining layers, + // and falling back from partial to ordinary layer download is refused + // (because the mitigation would be the only safety net for that path). + filteredLayerIndices []int + signatures []byte // Signature contents, temporary + signatureses map[digest.Digest][]byte // Instance signature contents, temporary + metadata storageImageMetadata // Metadata contents being built // Mapping from layer (by index) to the associated ID in the storage. // It's protected *implicitly* since `commitLayer()`, at any given @@ -221,6 +226,17 @@ func (s *storageImageDestination) NoteOriginalOCIConfig(ociConfig *imgspecv1.Ima return nil } +// FilterLayers inspects the manifest layer infos and returns indices of layers +// that should be skipped. For c/storage, this detects the zstd:chunked +// sentinel layer and records that the full-digest mitigation can be skipped. +func (s *storageImageDestination) FilterLayers(layerInfos []manifest.LayerInfo) []int { + if len(layerInfos) > 0 && layerInfos[0].Digest == toc.ZstdChunkedSentinelDigest { + s.filteredLayerIndices = []int{0} + return s.filteredLayerIndices + } + return nil +} + // PutBlobWithOptions writes contents of stream and returns data representing the result. // inputInfo.Digest can be optionally provided if known; if provided, and stream is read to the end without error, the digest MUST match the stream contents. // inputInfo.Size is the expected length of stream, if known. @@ -396,6 +412,9 @@ func (s *storageImageDestination) PutBlobPartial(ctx context.Context, chunkAcces return private.UploadedBlob{}, fmt.Errorf("internal error: in PutBlobPartial, untrustedLayerDiffID returned errUntrustedLayerDiffIDNotYetAvailable") case errors.As(err, &diffIDUnknownErr): if inputTOCDigest != nil { + if len(s.filteredLayerIndices) > 0 { + return private.UploadedBlob{}, fmt.Errorf("zstd:chunked sentinel present, refusing fallback to ordinary layer download: %w", err) + } return private.UploadedBlob{}, private.NewErrFallbackToOrdinaryLayerDownload(err) } untrustedDiffID = "" // A schema1 image or a non-TOC layer with no ambiguity, let it through @@ -415,6 +434,10 @@ func (s *storageImageDestination) PutBlobPartial(ctx context.Context, chunkAcces defer func() { var perr chunked.ErrFallbackToOrdinaryLayerDownload if errors.As(retErr, &perr) { + if len(s.filteredLayerIndices) > 0 { + retErr = fmt.Errorf("zstd:chunked sentinel present, refusing fallback to ordinary layer download: %w", retErr) + return + } retErr = private.NewErrFallbackToOrdinaryLayerDownload(retErr) } }() @@ -424,6 +447,16 @@ func (s *storageImageDestination) PutBlobPartial(ctx context.Context, chunkAcces return private.UploadedBlob{}, err } defer differ.Close() + if len(s.filteredLayerIndices) > 0 { + // Skipping the mitigation is safe because the sentinel layer guarantees + // that only TOC-aware clients will process this image. With the mitigation + // skipped, we also refuse any fallback to ordinary layer download (see the + // checks above and the deferred error handler). + logrus.Debugf("Sentinel detected for layer %s: skipping full-digest mitigation", srcInfo.Digest) + if err := chunked.SkipMitigation(differ); err != nil { + return private.UploadedBlob{}, err + } + } out, err := s.imageRef.transport.store.PrepareStagedLayer(nil, differ) if err != nil { @@ -795,7 +828,7 @@ func (s *storageImageDestination) computeID(m manifest.Manifest) (string, error) case *manifest.Schema1: // Build a list of the diffIDs we've generated for the non-throwaway FS layers for i, li := range layerInfos { - if li.EmptyLayer { + if li.EmptyLayer || slices.Contains(s.filteredLayerIndices, i) { continue } trusted, ok := s.trustedLayerIdentityDataLocked(i, li.Digest) @@ -839,6 +872,9 @@ func (s *storageImageDestination) computeID(m manifest.Manifest) (string, error) tocIDInput := "" hasLayerPulledByTOC := false for i, li := range layerInfos { + if li.EmptyLayer || slices.Contains(s.filteredLayerIndices, i) { + continue + } trusted, ok := s.trustedLayerIdentityDataLocked(i, li.Digest) if !ok { // We have already committed all layers if we get to this point, so the data must have been available. return "", fmt.Errorf("internal inconsistency: layer (%d, %q) not found", i, li.Digest) @@ -1453,9 +1489,10 @@ func (s *storageImageDestination) CommitWithOptions(ctx context.Context, options // Extract, commit, or find the layers. for i, blob := range layerBlobs { + isFiltered := slices.Contains(s.filteredLayerIndices, i) if stopQueue, err := s.commitLayer(i, addedLayerInfo{ digest: blob.Digest, - emptyLayer: blob.EmptyLayer, + emptyLayer: blob.EmptyLayer || isFiltered, }, blob.Size); err != nil { return err } else if stopQueue { diff --git a/image/storage/storage_src.go b/image/storage/storage_src.go index e76b9ceb03..ea58517d73 100644 --- a/image/storage/storage_src.go +++ b/image/storage/storage_src.go @@ -352,10 +352,26 @@ func (s *storageImageSource) LayerInfosForCopy(ctx context.Context, instanceDige } slices.Reverse(physicalBlobInfos) - res, err := buildLayerInfosForCopy(man.LayerInfos(), physicalBlobInfos, gzipCompressedLayerType) + manifestLayerInfos := man.LayerInfos() + // The zstd:chunked sentinel layer was never stored physically; + // strip it before matching against physical layers, then prepend its blob info to the result. + var sentinelBlobInfo *types.BlobInfo + if len(manifestLayerInfos) > 0 && manifestLayerInfos[0].Digest == toc.ZstdChunkedSentinelDigest { + sentinelBlobInfo = &types.BlobInfo{ + Digest: manifestLayerInfos[0].Digest, + Size: int64(len(toc.ZstdChunkedSentinelContent)), + MediaType: manifestLayerInfos[0].MediaType, + } + manifestLayerInfos = manifestLayerInfos[1:] + } + + res, err := buildLayerInfosForCopy(manifestLayerInfos, physicalBlobInfos, gzipCompressedLayerType) if err != nil { return nil, fmt.Errorf("creating LayerInfosForCopy of image %q: %w", s.image.ID, err) } + if sentinelBlobInfo != nil { + res = append([]types.BlobInfo{*sentinelBlobInfo}, res...) + } return res, nil } From 8cfcfca71571d7cef93f62207ed9d030df860b67 Mon Sep 17 00:00:00 2001 From: Giuseppe Scrivano Date: Thu, 5 Mar 2026 12:17:15 +0000 Subject: [PATCH 4/4] copy: create zstd:chunked sentinel variants when pushing images When pushing images, generate zstd:chunked sentinel variant manifests. For manifest lists, create sentinel variants for each instance after copying. For single images, wrap the result in an OCI index with a sentinel variant when the destination supports it. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Giuseppe Scrivano --- image/copy/copy.go | 12 ++ image/copy/multiple.go | 277 +++++++++++++++++++++++++++++++++++ image/copy/single.go | 29 ++-- image/storage/storage_src.go | 4 + 4 files changed, 309 insertions(+), 13 deletions(-) diff --git a/image/copy/copy.go b/image/copy/copy.go index 5d70482519..41c6ccbc76 100644 --- a/image/copy/copy.go +++ b/image/copy/copy.go @@ -332,6 +332,18 @@ func Image(ctx context.Context, policyContext *signature.PolicyContext, destRef, return nil, err } copiedManifest = single.manifest + // If the copied single image has zstd:chunked layers and the destination + // supports manifest lists, wrap it in an OCI index with a sentinel variant. + // Skip when digests must be preserved or destination is a digested reference. + if !options.PreserveDigests && supportsMultipleImages(c.dest) { + indexManifest, err := c.createSingleImageSentinelIndex(ctx, single) + if err != nil { + return nil, err + } + if indexManifest != nil { + copiedManifest = indexManifest + } + } } else if c.options.ImageListSelection == CopySystemImage { if len(options.EnsureCompressionVariantsExist) > 0 { return nil, fmt.Errorf("EnsureCompressionVariantsExist is not implemented when not creating a multi-architecture image") diff --git a/image/copy/multiple.go b/image/copy/multiple.go index 4ee57e5f58..aec167b062 100644 --- a/image/copy/multiple.go +++ b/image/copy/multiple.go @@ -3,6 +3,7 @@ package copy import ( "bytes" "context" + "encoding/json" "errors" "fmt" "maps" @@ -16,9 +17,12 @@ import ( "go.podman.io/image/v5/docker/reference" "go.podman.io/image/v5/internal/image" internalManifest "go.podman.io/image/v5/internal/manifest" + "go.podman.io/image/v5/internal/private" "go.podman.io/image/v5/internal/set" "go.podman.io/image/v5/manifest" "go.podman.io/image/v5/pkg/compression" + "go.podman.io/image/v5/types" + chunkedToc "go.podman.io/storage/pkg/chunked/toc" ) type instanceCopyKind int @@ -28,6 +32,14 @@ const ( instanceCopyClone ) +// copiedInstanceData stores info about a successfully copied instance, +// used for creating sentinel variants. +type copiedInstanceData struct { + sourceDigest digest.Digest + result copySingleImageResult + platform *imgspecv1.Platform +} + type instanceCopy struct { op instanceCopyKind sourceDigest digest.Digest @@ -283,6 +295,8 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte, if err != nil { return nil, fmt.Errorf("preparing instances for copy: %w", err) } + var copiedInstances []copiedInstanceData + c.Printf("Copying %d images generated from %d images in list\n", len(instanceCopyList), len(instanceDigests)) for i, instance := range instanceCopyList { // Update instances to be edited by their `ListOperation` and @@ -305,6 +319,15 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte, UpdateCompressionAlgorithms: updated.compressionAlgorithms, UpdateMediaType: updated.manifestMIMEType, }) + // Capture instance data for sentinel variant creation + instanceDetails, detailsErr := updatedList.Instance(instance.sourceDigest) + if detailsErr == nil { + copiedInstances = append(copiedInstances, copiedInstanceData{ + sourceDigest: instance.sourceDigest, + result: updated, + platform: instanceDetails.ReadOnly.Platform, + }) + } case instanceCopyClone: logrus.Debugf("Replicating instance %s (%d/%d)", instance.sourceDigest, i+1, len(instanceCopyList)) c.Printf("Replicating image %s (%d/%d)\n", instance.sourceDigest, i+1, len(instanceCopyList)) @@ -333,6 +356,15 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte, } } + // Create zstd:chunked sentinel variants for instances where any layer uses zstd:chunked. + if cannotModifyManifestListReason == "" { + sentinelEdits, err := c.createZstdChunkedSentinelVariants(ctx, copiedInstances, updatedList) + if err != nil { + return nil, fmt.Errorf("creating zstd:chunked sentinel variants: %w", err) + } + instanceEdits = append(instanceEdits, sentinelEdits...) + } + // Now reset the digest/size/types of the manifests in the list to account for any conversions that we made. if err = updatedList.EditInstances(instanceEdits, cannotModifyManifestListReason != ""); err != nil { return nil, fmt.Errorf("updating manifest list: %w", err) @@ -405,3 +437,248 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte, return manifestList, nil } + +// hasZstdChunkedLayers returns true if any non-empty layer in the manifest has +// zstd:chunked TOC annotations. +func hasZstdChunkedLayers(ociMan *manifest.OCI1) bool { + for _, l := range ociMan.LayerInfos() { + if l.EmptyLayer { + continue + } + d, err := chunkedToc.GetTOCDigest(l.Annotations) + if err == nil && d != nil { + return true + } + } + return false +} + +// pushSentinelVariant creates and pushes a sentinel variant of the given OCI manifest. +// It prepends a sentinel layer and DiffID, creates a new config, and pushes everything +// to the destination. Returns the serialized sentinel manifest and its digest. +func (c *copier) pushSentinelVariant(ctx context.Context, ociMan *manifest.OCI1, ociConfig *imgspecv1.Image) ([]byte, digest.Digest, error) { + sentinelContent := chunkedToc.ZstdChunkedSentinelContent + + // Push sentinel blob (content-addressed, so idempotent if already pushed). + sentinelBlobInfo := types.BlobInfo{ + Digest: chunkedToc.ZstdChunkedSentinelDigest, + Size: int64(len(sentinelContent)), + } + reused, _, err := c.dest.TryReusingBlobWithOptions(ctx, sentinelBlobInfo, + private.TryReusingBlobOptions{Cache: c.blobInfoCache}) + if err != nil { + return nil, "", fmt.Errorf("checking sentinel blob: %w", err) + } + if !reused { + _, err = c.dest.PutBlobWithOptions(ctx, bytes.NewReader(sentinelContent), + sentinelBlobInfo, private.PutBlobOptions{Cache: c.blobInfoCache}) + if err != nil { + return nil, "", fmt.Errorf("pushing sentinel blob: %w", err) + } + } + + // Create new config with sentinel DiffID prepended. + newDiffIDs := make([]digest.Digest, 0, len(ociConfig.RootFS.DiffIDs)+1) + newDiffIDs = append(newDiffIDs, chunkedToc.ZstdChunkedSentinelDigest) + newDiffIDs = append(newDiffIDs, ociConfig.RootFS.DiffIDs...) + ociConfig.RootFS.DiffIDs = newDiffIDs + configBlob, err := json.Marshal(ociConfig) + if err != nil { + return nil, "", fmt.Errorf("marshaling sentinel config: %w", err) + } + configDigest := digest.FromBytes(configBlob) + + // Push new config. + configBlobInfo := types.BlobInfo{ + Digest: configDigest, + Size: int64(len(configBlob)), + MediaType: imgspecv1.MediaTypeImageConfig, + } + reused, _, err = c.dest.TryReusingBlobWithOptions(ctx, configBlobInfo, + private.TryReusingBlobOptions{Cache: c.blobInfoCache}) + if err != nil { + return nil, "", fmt.Errorf("checking sentinel config: %w", err) + } + if !reused { + _, err = c.dest.PutBlobWithOptions(ctx, bytes.NewReader(configBlob), + configBlobInfo, private.PutBlobOptions{Cache: c.blobInfoCache, IsConfig: true}) + if err != nil { + return nil, "", fmt.Errorf("pushing sentinel config: %w", err) + } + } + + // Build sentinel manifest: sentinel layer + original layers. + newLayers := make([]imgspecv1.Descriptor, 0, len(ociMan.Layers)+1) + newLayers = append(newLayers, imgspecv1.Descriptor{ + MediaType: imgspecv1.MediaTypeImageLayerZstd, + Digest: chunkedToc.ZstdChunkedSentinelDigest, + Size: int64(len(sentinelContent)), + }) + newLayers = append(newLayers, ociMan.Layers...) + + sentinelOCI := manifest.OCI1FromComponents(imgspecv1.Descriptor{ + MediaType: imgspecv1.MediaTypeImageConfig, + Digest: configDigest, + Size: int64(len(configBlob)), + }, newLayers) + sentinelManifestBlob, err := sentinelOCI.Serialize() + if err != nil { + return nil, "", fmt.Errorf("serializing sentinel manifest: %w", err) + } + sentinelManifestDigest := digest.FromBytes(sentinelManifestBlob) + + // Push sentinel manifest. + if err := c.dest.PutManifest(ctx, sentinelManifestBlob, &sentinelManifestDigest); err != nil { + return nil, "", fmt.Errorf("pushing sentinel manifest: %w", err) + } + + return sentinelManifestBlob, sentinelManifestDigest, nil +} + +// createZstdChunkedSentinelVariants creates sentinel variants for instances +// where any layer uses zstd:chunked. The sentinel variant has a non-tar sentinel +// layer prepended, signaling aware clients to skip the full-digest mitigation. +// It returns ListEdit entries to add the sentinel variants to the manifest list. +func (c *copier) createZstdChunkedSentinelVariants(ctx context.Context, copiedInstances []copiedInstanceData, updatedList internalManifest.List) ([]internalManifest.ListEdit, error) { + var edits []internalManifest.ListEdit + + // Check which platforms already have a sentinel variant in the source. + platformsWithSentinel := set.New[platformComparable]() + for _, d := range updatedList.Instances() { + details, err := updatedList.Instance(d) + if err != nil { + continue + } + if details.ReadOnly.Annotations[internalManifest.OCI1InstanceAnnotationZstdChunkedSentinel] == internalManifest.OCI1InstanceAnnotationZstdChunkedSentinelValue { + platformsWithSentinel.Add(platformV1ToPlatformComparable(details.ReadOnly.Platform)) + } + } + + for _, ci := range copiedInstances { + // Only handle OCI manifests (zstd:chunked is OCI-only). + if ci.result.manifestMIMEType != imgspecv1.MediaTypeImageManifest { + continue + } + + // Skip if this platform already has a sentinel variant. + if platformsWithSentinel.Contains(platformV1ToPlatformComparable(ci.platform)) { + continue + } + + ociMan, err := manifest.OCI1FromManifest(ci.result.manifest) + if err != nil { + logrus.Debugf("Cannot parse manifest for sentinel variant: %v", err) + continue + } + + if !hasZstdChunkedLayers(ociMan) { + continue + } + + // Use the config as written to the destination (reflects any edits during copy). + var ociConfig imgspecv1.Image + if err := json.Unmarshal(ci.result.configBlob, &ociConfig); err != nil { + logrus.Debugf("Cannot parse config for sentinel variant: %v", err) + continue + } + + sentinelManifestBlob, sentinelManifestDigest, err := c.pushSentinelVariant(ctx, ociMan, &ociConfig) + if err != nil { + return nil, err + } + + edits = append(edits, internalManifest.ListEdit{ + ListOperation: internalManifest.ListOpAdd, + AddDigest: sentinelManifestDigest, + AddSize: int64(len(sentinelManifestBlob)), + AddMediaType: imgspecv1.MediaTypeImageManifest, + AddPlatform: ci.platform, + AddAnnotations: map[string]string{ + internalManifest.OCI1InstanceAnnotationZstdChunkedSentinel: internalManifest.OCI1InstanceAnnotationZstdChunkedSentinelValue, + internalManifest.OCI1InstanceAnnotationCompressionZSTD: internalManifest.OCI1InstanceAnnotationCompressionZSTDValue, + }, + AddCompressionAlgorithms: ci.result.compressionAlgorithms, + }) + + platformsWithSentinel.Add(platformV1ToPlatformComparable(ci.platform)) + } + + return edits, nil +} + +// createSingleImageSentinelIndex checks if a single copied image has zstd:chunked +// layers, and if so, creates a sentinel variant and wraps both in an OCI index. +// Returns the serialized index manifest, or nil if no sentinel was needed. +func (c *copier) createSingleImageSentinelIndex(ctx context.Context, single copySingleImageResult) ([]byte, error) { + if single.manifestMIMEType != imgspecv1.MediaTypeImageManifest { + return nil, nil + } + + ociMan, err := manifest.OCI1FromManifest(single.manifest) + if err != nil { + return nil, nil + } + + if !hasZstdChunkedLayers(ociMan) { + return nil, nil + } + + // Use the config as written to the destination (reflects any edits during copy). + var ociConfig imgspecv1.Image + if err := json.Unmarshal(single.configBlob, &ociConfig); err != nil { + logrus.Debugf("Cannot parse config for single-image sentinel: %v", err) + return nil, nil + } + + sentinelManifestBlob, sentinelManifestDigest, err := c.pushSentinelVariant(ctx, ociMan, &ociConfig) + if err != nil { + return nil, err + } + + // Extract platform from config. + var platform *imgspecv1.Platform + if ociConfig.OS != "" || ociConfig.Architecture != "" { + platform = &imgspecv1.Platform{ + OS: ociConfig.OS, + Architecture: ociConfig.Architecture, + Variant: ociConfig.Variant, + OSVersion: ociConfig.OSVersion, + } + } + + // Build OCI index with: original manifest first, sentinel variant last. + // Both entries get the zstd annotation so that old clients (which prefer + // zstd over gzip) fall through to position-based selection and pick the + // original at position 0 instead of the sentinel. + index := manifest.OCI1IndexFromComponents([]imgspecv1.Descriptor{ + { + MediaType: imgspecv1.MediaTypeImageManifest, + Digest: single.manifestDigest, + Size: int64(len(single.manifest)), + Platform: platform, + Annotations: map[string]string{ + internalManifest.OCI1InstanceAnnotationCompressionZSTD: internalManifest.OCI1InstanceAnnotationCompressionZSTDValue, + }, + }, + { + MediaType: imgspecv1.MediaTypeImageManifest, + Digest: sentinelManifestDigest, + Size: int64(len(sentinelManifestBlob)), + Platform: platform, + Annotations: map[string]string{ + internalManifest.OCI1InstanceAnnotationZstdChunkedSentinel: internalManifest.OCI1InstanceAnnotationZstdChunkedSentinelValue, + internalManifest.OCI1InstanceAnnotationCompressionZSTD: internalManifest.OCI1InstanceAnnotationCompressionZSTDValue, + }, + }, + }, nil) + indexBlob, err := index.Serialize() + if err != nil { + return nil, fmt.Errorf("serializing sentinel index: %w", err) + } + + if err := c.dest.PutManifest(ctx, indexBlob, nil); err != nil { + return nil, fmt.Errorf("pushing sentinel index: %w", err) + } + + return indexBlob, nil +} diff --git a/image/copy/single.go b/image/copy/single.go index 0d203d1fa0..4ade53492e 100644 --- a/image/copy/single.go +++ b/image/copy/single.go @@ -58,6 +58,7 @@ type copySingleImageResult struct { manifestMIMEType string manifestDigest digest.Digest compressionAlgorithms []compressiontypes.Algorithm + configBlob []byte // The config as written to the destination } // copySingleImage copies a single (non-manifest-list) image unparsedImage, using c.policyContext to validate @@ -243,11 +244,12 @@ func (c *copier) copySingleImage(ctx context.Context, unparsedImage *image.Unpar // without actually trying to upload something and getting a types.ManifestTypeRejectedError. // So, try the preferred manifest MIME type with possibly-updated blob digests, media types, and sizes if // we're altering how they're compressed. If the process succeeds, fine… - manifestBytes, manifestDigest, err := ic.copyUpdatedConfigAndManifest(ctx, targetInstance) + manifestBytes, manifestDigest, configBlob, err := ic.copyUpdatedConfigAndManifest(ctx, targetInstance) wipResult := copySingleImageResult{ manifest: manifestBytes, manifestMIMEType: ic.manifestConversionPlan.preferredMIMEType, manifestDigest: manifestDigest, + configBlob: configBlob, } if err != nil { logrus.Debugf("Writing manifest using preferred type %s failed: %v", ic.manifestConversionPlan.preferredMIMEType, err) @@ -278,7 +280,7 @@ func (c *copier) copySingleImage(ctx context.Context, unparsedImage *image.Unpar for _, manifestMIMEType := range ic.manifestConversionPlan.otherMIMETypeCandidates { logrus.Debugf("Trying to use manifest type %s…", manifestMIMEType) ic.manifestUpdates.ManifestMIMEType = manifestMIMEType - attemptedManifest, attemptedManifestDigest, err := ic.copyUpdatedConfigAndManifest(ctx, targetInstance) + attemptedManifest, attemptedManifestDigest, attemptedConfigBlob, err := ic.copyUpdatedConfigAndManifest(ctx, targetInstance) if err != nil { logrus.Debugf("Upload of manifest type %s failed: %v", manifestMIMEType, err) errs = append(errs, fmt.Sprintf("%s(%v)", manifestMIMEType, err)) @@ -290,6 +292,7 @@ func (c *copier) copySingleImage(ctx context.Context, unparsedImage *image.Unpar manifest: attemptedManifest, manifestMIMEType: manifestMIMEType, manifestDigest: attemptedManifestDigest, + configBlob: attemptedConfigBlob, } errs = nil // Mark this as a success so that we don't abort below. break @@ -600,11 +603,11 @@ func layerDigestsDiffer(a, b []types.BlobInfo) bool { // copyUpdatedConfigAndManifest updates the image per ic.manifestUpdates, if necessary, // stores the resulting config and manifest to the destination, and returns the stored manifest // and its digest. -func (ic *imageCopier) copyUpdatedConfigAndManifest(ctx context.Context, instanceDigest *digest.Digest) ([]byte, digest.Digest, error) { +func (ic *imageCopier) copyUpdatedConfigAndManifest(ctx context.Context, instanceDigest *digest.Digest) ([]byte, digest.Digest, []byte, error) { var pendingImage types.Image = ic.src if !ic.noPendingManifestUpdates() { if ic.cannotModifyManifestReason != "" { - return nil, "", fmt.Errorf("Internal error: copy needs an updated manifest but that was known to be forbidden: %q", ic.cannotModifyManifestReason) + return nil, "", nil, fmt.Errorf("Internal error: copy needs an updated manifest but that was known to be forbidden: %q", ic.cannotModifyManifestReason) } if !ic.diffIDsAreNeeded && ic.src.UpdatedImageNeedsLayerDiffIDs(*ic.manifestUpdates) { // We have set ic.diffIDsAreNeeded based on the preferred MIME type returned by determineManifestConversion. @@ -613,48 +616,48 @@ func (ic *imageCopier) copyUpdatedConfigAndManifest(ctx context.Context, instanc // when ic.c.dest.SupportedManifestMIMETypes() includes both s1 and s2, the upload using s1 failed, and we are now trying s2. // Supposedly s2-only registries do not exist or are extremely rare, so failing with this error message is good enough for now. // If handling such registries turns out to be necessary, we could compute ic.diffIDsAreNeeded based on the full list of manifest MIME type candidates. - return nil, "", fmt.Errorf("Can not convert image to %s, preparing DiffIDs for this case is not supported", ic.manifestUpdates.ManifestMIMEType) + return nil, "", nil, fmt.Errorf("Can not convert image to %s, preparing DiffIDs for this case is not supported", ic.manifestUpdates.ManifestMIMEType) } pi, err := ic.src.UpdatedImage(ctx, *ic.manifestUpdates) if err != nil { - return nil, "", fmt.Errorf("creating an updated image manifest: %w", err) + return nil, "", nil, fmt.Errorf("creating an updated image manifest: %w", err) } pendingImage = pi } man, _, err := pendingImage.Manifest(ctx) if err != nil { - return nil, "", fmt.Errorf("reading manifest: %w", err) + return nil, "", nil, fmt.Errorf("reading manifest: %w", err) } configBlob, err := pendingImage.ConfigBlob(ctx) if err != nil { - return nil, "", fmt.Errorf("reading config blob: %w", err) + return nil, "", nil, fmt.Errorf("reading config blob: %w", err) } if ic.stripSentinel { man, configBlob, err = ic.stripSentinelFromManifestAndConfig(ctx, man, configBlob) if err != nil { - return nil, "", err + return nil, "", nil, err } } else { if err := ic.copyConfig(ctx, pendingImage); err != nil { - return nil, "", err + return nil, "", nil, err } } ic.c.Printf("Writing manifest to image destination\n") manifestDigest, err := manifest.Digest(man) if err != nil { - return nil, "", err + return nil, "", nil, err } if instanceDigest != nil { instanceDigest = &manifestDigest } if err := ic.c.dest.PutManifest(ctx, man, instanceDigest); err != nil { logrus.Debugf("Error %v while writing manifest %q", err, string(man)) - return nil, "", fmt.Errorf("writing manifest: %w", err) + return nil, "", nil, fmt.Errorf("writing manifest: %w", err) } - return man, manifestDigest, nil + return man, manifestDigest, configBlob, nil } // stripSentinelFromManifestAndConfig removes the sentinel layer (layer[0]) from diff --git a/image/storage/storage_src.go b/image/storage/storage_src.go index ea58517d73..7cfa6e114d 100644 --- a/image/storage/storage_src.go +++ b/image/storage/storage_src.go @@ -122,6 +122,10 @@ func (s *storageImageSource) GetBlob(ctx context.Context, info types.BlobInfo, c return io.NopCloser(bytes.NewReader(image.GzippedEmptyLayer)), int64(len(image.GzippedEmptyLayer)), nil } + if digest == toc.ZstdChunkedSentinelDigest { + return io.NopCloser(bytes.NewReader(toc.ZstdChunkedSentinelContent)), int64(len(toc.ZstdChunkedSentinelContent)), nil + } + var layers []storage.Layer // This lookup path is strictly necessary for layers identified by TOC digest