Skip to content

Commit f15717e

Browse files
committed
image/copy: Allow user to force digest algorithm in CLI ops like skopeo copy
Add support for forcing a specific digest algorithm (e.g., sha512) when copying images. This allows users to override the default sha256 digest algorithm used by most transports. Key changes: - Add BrokenSetForceDestinationDigestAlgorithm() API to configure digest forcing (marked as unstable/incomplete, will be renamed when feature is complete) - Use digests.Options.Choose() throughout to validate and enforce digest choices - Add updateManifestConfigDigest() helper using manifest abstraction layer - Modify copyConfig() to return new config digest when algorithm changes - Clean failures for unsupported combinations: * Multi-arch images (not yet implemented) * Docker schema1 manifests (only supports sha256) * zstd:chunked compression with non-sha256 (falls back to plain zstd) * PutBlobPartial when forcing different algorithm (disabled) * Blob reuse across digest algorithms (skipped with warning) Signed-off-by: Lokesh Mandvekar <lsm5@redhat.com>
1 parent 358b694 commit f15717e

16 files changed

Lines changed: 250 additions & 28 deletions

image/copy/blob.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,10 @@ func (ic *imageCopier) copyBlobFromStream(ctx context.Context, srcReader io.Read
138138
return types.BlobInfo{}, fmt.Errorf("Internal error writing blob %s, digest verification failed but was ignored", srcInfo.Digest)
139139
}
140140
if stream.info.Digest != "" && uploadedInfo.Digest != stream.info.Digest {
141-
return types.BlobInfo{}, fmt.Errorf("Internal error writing blob %s, blob with digest %s saved with digest %s", srcInfo.Digest, stream.info.Digest, uploadedInfo.Digest)
141+
// If algorithms match, the whole digest values must match
142+
if stream.info.Digest.Algorithm() == uploadedInfo.Digest.Algorithm() {
143+
return types.BlobInfo{}, fmt.Errorf("Internal error writing blob %s, blob with digest %s saved with digest %s", srcInfo.Digest, stream.info.Digest, uploadedInfo.Digest)
144+
}
142145
}
143146
if digestingReader.validationSucceeded {
144147
if err := compressionStep.recordValidatedDigestData(ic.c, uploadedInfo, srcInfo, encryptionStep, decryptionStep); err != nil {

image/copy/copy.go

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,26 @@ type Options struct {
167167
digestOptions digests.Options
168168
}
169169

170+
// BrokenSetForceDestinationDigestAlgorithm forces the use of a specific digest algorithm when writing to the destination.
171+
//
172+
// UNSTABLE API: This API is incomplete and may be changed or removed at any time.
173+
// It currently only enforces the digest algorithm for a subset of transports and operations.
174+
// See https://github.com/containers/container-libs/pull/552 for implementation status.
175+
func (o *Options) BrokenSetForceDestinationDigestAlgorithm(algo digest.Algorithm) error {
176+
if o.digestOptions.MustUseSet() != "" {
177+
return fmt.Errorf("digest options are already configured")
178+
}
179+
if !algo.Available() {
180+
return fmt.Errorf("digest algorithm %q is not available", algo.String())
181+
}
182+
digestOpts, err := digests.MustUse(algo)
183+
if err != nil {
184+
return fmt.Errorf("failed to set force-digest algorithm: %w", err)
185+
}
186+
o.digestOptions = digestOpts
187+
return nil
188+
}
189+
170190
// OptionCompressionVariant allows to supply information about
171191
// selected compression algorithm and compression level by the
172192
// end-user. Refer to EnsureCompressionVariantsExist to know
@@ -210,12 +230,13 @@ func Image(ctx context.Context, policyContext *signature.PolicyContext, destRef,
210230
if options == nil {
211231
options = &Options{}
212232
}
213-
// FIXME: Currently, digestsOptions is not implemented at all, and exists in the codebase
214-
// only to allow gradually building the feature set.
215-
// After c/image/copy consistently implements it, provide a public digest options API of some kind.
216-
optionsCopy := *options
217-
optionsCopy.digestOptions = digests.CanonicalDefault()
218-
options = &optionsCopy
233+
// FIXME: digestsOptions exists to gradually build the feature. Provide public API once fully implemented.
234+
// Set default only if not configured by BrokenSetForceDestinationDigestAlgorithm
235+
if options.digestOptions.MustUseSet() == "" {
236+
optionsCopy := *options
237+
optionsCopy.digestOptions = digests.CanonicalDefault()
238+
options = &optionsCopy
239+
}
219240

220241
if err := validateImageListSelection(options.ImageListSelection); err != nil {
221242
return nil, err

image/copy/multiple.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,12 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
208208
cannotModifyManifestListReason = "Instructed to preserve digests"
209209
}
210210

211+
// FIXME: Multi-arch not supported yet; would need config digest updates for each instance.
212+
// See https://github.com/containers/container-libs/pull/552#discussion_r2611627578
213+
if c.options.digestOptions.MustUseSet() != "" {
214+
return nil, fmt.Errorf("forcing digest algorithm with multi-arch images is not yet implemented")
215+
}
216+
211217
// Determine if we'll need to convert the manifest list to a different format.
212218
forceListMIMEType := c.options.ForceManifestMIMEType
213219
switch forceListMIMEType {

image/copy/single.go

Lines changed: 108 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/sirupsen/logrus"
1919
"github.com/vbauerster/mpb/v8"
2020
"go.podman.io/image/v5/docker/reference"
21+
"go.podman.io/image/v5/internal/digests"
2122
"go.podman.io/image/v5/internal/image"
2223
"go.podman.io/image/v5/internal/pkg/platform"
2324
"go.podman.io/image/v5/internal/private"
@@ -173,6 +174,21 @@ func (c *copier) copySingleImage(ctx context.Context, unparsedImage *image.Unpar
173174
}
174175
}
175176

177+
// FIXME: zstd:chunked requires sha256 for TOC/chunk digests; fall back to plain zstd with other algorithms.
178+
if forcedAlgo := ic.c.options.digestOptions.MustUseSet(); forcedAlgo != "" && forcedAlgo != digest.SHA256 {
179+
format := ic.compressionFormat
180+
if format == nil {
181+
format = defaultCompressionFormat
182+
}
183+
if format.Name() == compressiontypes.ZstdChunkedAlgorithmName {
184+
if ic.requireCompressionFormatMatch {
185+
return copySingleImageResult{}, fmt.Errorf("cannot combine zstd:chunked with forced digest algorithm %s (zstd:chunked currently only supports sha256); use plain zstd instead", forcedAlgo)
186+
}
187+
logrus.Warnf("Compression using zstd:chunked with forced digest algorithm %s is not supported (zstd:chunked uses sha256 for internal digests), using plain zstd instead", forcedAlgo)
188+
ic.compressionFormat = &compression.Zstd
189+
}
190+
}
191+
176192
// Decide whether we can substitute blobs with semantic equivalents:
177193
// - Don’t do that if we can’t modify the manifest at all
178194
// - Ensure _this_ copy sees exactly the intended data when either processing a signed image or signing it.
@@ -200,6 +216,15 @@ func (c *copier) copySingleImage(ctx context.Context, unparsedImage *image.Unpar
200216
if err != nil {
201217
return copySingleImageResult{}, err
202218
}
219+
220+
// Schema1 lacks separate config and only supports sha256; fail early with other algorithms.
221+
if forcedAlgo := ic.c.options.digestOptions.MustUseSet(); forcedAlgo != "" && forcedAlgo != digest.SHA256 {
222+
isSchema1 := ic.manifestConversionPlan.preferredMIMEType == manifest.DockerV2Schema1MediaType ||
223+
ic.manifestConversionPlan.preferredMIMEType == manifest.DockerV2Schema1SignedMediaType
224+
if isSchema1 {
225+
return copySingleImageResult{}, fmt.Errorf("cannot force digest algorithm %s with Docker schema1 manifests (only sha256 is supported)", forcedAlgo)
226+
}
227+
}
203228
// We set up this part of ic.manifestUpdates quite early, not just around the
204229
// code that calls copyUpdatedConfigAndManifest, so that other parts of the copy code
205230
// (e.g. the UpdatedImageNeedsLayerDiffIDs check just below) can make decisions based
@@ -592,12 +617,27 @@ func (ic *imageCopier) copyUpdatedConfigAndManifest(ctx context.Context, instanc
592617
return nil, "", fmt.Errorf("reading manifest: %w", err)
593618
}
594619

595-
if err := ic.copyConfig(ctx, pendingImage); err != nil {
620+
newConfigDigest, err := ic.copyConfig(ctx, pendingImage)
621+
if err != nil {
596622
return nil, "", err
597623
}
598624

625+
// FIXME: Single image only; multi-arch needs per-instance config digest updates.
626+
// See https://github.com/containers/container-libs/pull/552#discussion_r2611627578
627+
if newConfigDigest != nil {
628+
man, err = ic.updateManifestConfigDigest(man, pendingImage, *newConfigDigest)
629+
if err != nil {
630+
return nil, "", fmt.Errorf("updating manifest config digest: %w", err)
631+
}
632+
}
633+
599634
ic.c.Printf("Writing manifest to image destination\n")
600-
manifestDigest, err := manifest.Digest(man)
635+
// Choose the digest algorithm based on digest options
636+
manifestDigestAlgo, err := ic.c.options.digestOptions.Choose(digests.Situation{})
637+
if err != nil {
638+
return nil, "", fmt.Errorf("choosing manifest digest algorithm: %w", err)
639+
}
640+
manifestDigest, err := manifest.DigestWithAlgorithm(man, manifestDigestAlgo)
601641
if err != nil {
602642
return nil, "", err
603643
}
@@ -611,13 +651,33 @@ func (ic *imageCopier) copyUpdatedConfigAndManifest(ctx context.Context, instanc
611651
return man, manifestDigest, nil
612652
}
613653

654+
// updateManifestConfigDigest updates the config digest in the manifest using the manifest abstraction layer.
655+
func (ic *imageCopier) updateManifestConfigDigest(manifestBlob []byte, src types.Image, newConfigDigest digest.Digest) ([]byte, error) {
656+
_, mt, err := src.Manifest(context.Background())
657+
if err != nil {
658+
return nil, fmt.Errorf("getting manifest type: %w", err)
659+
}
660+
661+
m, err := manifest.FromBlob(manifestBlob, mt)
662+
if err != nil {
663+
return nil, fmt.Errorf("parsing manifest: %w", err)
664+
}
665+
666+
if err := m.UpdateConfigDigest(newConfigDigest); err != nil {
667+
return nil, err
668+
}
669+
670+
return m.Serialize()
671+
}
672+
614673
// copyConfig copies config.json, if any, from src to dest.
615-
func (ic *imageCopier) copyConfig(ctx context.Context, src types.Image) error {
674+
// It returns the new config digest if it changed (due to digest algorithm forcing), or nil otherwise.
675+
func (ic *imageCopier) copyConfig(ctx context.Context, src types.Image) (*digest.Digest, error) {
616676
srcInfo := src.ConfigInfo()
617677
if srcInfo.Digest != "" {
618678
if err := ic.c.concurrentBlobCopiesSemaphore.Acquire(ctx, 1); err != nil {
619679
// This can only fail with ctx.Err(), so no need to blame acquiring the semaphore.
620-
return fmt.Errorf("copying config: %w", err)
680+
return nil, fmt.Errorf("copying config: %w", err)
621681
}
622682
defer ic.c.concurrentBlobCopiesSemaphore.Release(1)
623683

@@ -645,13 +705,17 @@ func (ic *imageCopier) copyConfig(ctx context.Context, src types.Image) error {
645705
return destInfo, nil
646706
}()
647707
if err != nil {
648-
return err
708+
return nil, err
649709
}
650710
if destInfo.Digest != srcInfo.Digest {
651-
return fmt.Errorf("Internal error: copying uncompressed config blob %s changed digest to %s", srcInfo.Digest, destInfo.Digest)
711+
// If algorithms match, the whole digest values must match
712+
if destInfo.Digest.Algorithm() == srcInfo.Digest.Algorithm() {
713+
return nil, fmt.Errorf("Internal error: copying uncompressed config blob %s changed digest to %s", srcInfo.Digest, destInfo.Digest)
714+
}
715+
return &destInfo.Digest, nil
652716
}
653717
}
654-
return nil
718+
return nil, nil
655719
}
656720

657721
// diffIDResult contains both a digest value and an error from diffIDComputationGoroutine.
@@ -718,7 +782,14 @@ func (ic *imageCopier) copyLayer(ctx context.Context, srcInfo types.BlobInfo, to
718782
// (e.g. if we know the DiffID of an encrypted compressed layer, it might not be necessary to pull, decrypt and decompress again),
719783
// but it’s not trivially safe to do such things, so until someone takes the effort to make a comprehensive argument, let’s not.
720784
encryptingOrDecrypting := toEncrypt || (isOciEncrypted(srcInfo.MediaType) && ic.c.options.OciDecryptConfig != nil)
721-
canAvoidProcessingCompleteLayer := !diffIDIsNeeded && !encryptingOrDecrypting
785+
// Forcing different digest requires processing full layer; PutBlobPartial incompatible.
786+
forcingDifferentDigestAlgo := false
787+
if forcedAlgo := ic.c.options.digestOptions.MustUseSet(); forcedAlgo != "" {
788+
if srcInfo.Digest.Algorithm() != forcedAlgo {
789+
forcingDifferentDigestAlgo = true
790+
}
791+
}
792+
canAvoidProcessingCompleteLayer := !diffIDIsNeeded && !encryptingOrDecrypting && !forcingDifferentDigestAlgo
722793

723794
// Don’t read the layer from the source if we already have the blob, and optimizations are acceptable.
724795
if canAvoidProcessingCompleteLayer {
@@ -743,19 +814,35 @@ func (ic *imageCopier) copyLayer(ctx context.Context, srcInfo types.BlobInfo, to
743814
tocDigest = *d
744815
}
745816

746-
reused, reusedBlob, err := ic.c.dest.TryReusingBlobWithOptions(ctx, srcInfo, private.TryReusingBlobOptions{
747-
Cache: ic.c.blobInfoCache,
748-
CanSubstitute: canSubstitute,
749-
EmptyLayer: emptyLayer,
750-
LayerIndex: &layerIndex,
751-
SrcRef: srcRef,
752-
PossibleManifestFormats: append([]string{ic.manifestConversionPlan.preferredMIMEType}, ic.manifestConversionPlan.otherMIMETypeCandidates...),
753-
RequiredCompression: requiredCompression,
754-
OriginalCompression: srcInfo.CompressionAlgorithm,
755-
TOCDigest: tocDigest,
756-
})
757-
if err != nil {
758-
return types.BlobInfo{}, "", fmt.Errorf("trying to reuse blob %s at destination: %w", srcInfo.Digest, err)
817+
// FIXME: Blob reuse disabled when forcing different digest algorithm.
818+
// Future: support cross-digest reuse via BlobInfoCache (like c/storage does).
819+
canTryReuse := true
820+
if forcedAlgo := ic.c.options.digestOptions.MustUseSet(); forcedAlgo != "" {
821+
if srcInfo.Digest.Algorithm() != forcedAlgo {
822+
logrus.Debugf("Skipping blob reuse for %s: digest algorithm %s doesn't match forced algorithm %s",
823+
srcInfo.Digest, srcInfo.Digest.Algorithm(), forcedAlgo)
824+
canTryReuse = false
825+
}
826+
}
827+
828+
reused := false
829+
var reusedBlob private.ReusedBlob
830+
if canTryReuse {
831+
var err error
832+
reused, reusedBlob, err = ic.c.dest.TryReusingBlobWithOptions(ctx, srcInfo, private.TryReusingBlobOptions{
833+
Cache: ic.c.blobInfoCache,
834+
CanSubstitute: canSubstitute,
835+
EmptyLayer: emptyLayer,
836+
LayerIndex: &layerIndex,
837+
SrcRef: srcRef,
838+
PossibleManifestFormats: append([]string{ic.manifestConversionPlan.preferredMIMEType}, ic.manifestConversionPlan.otherMIMETypeCandidates...),
839+
RequiredCompression: requiredCompression,
840+
OriginalCompression: srcInfo.CompressionAlgorithm,
841+
TOCDigest: tocDigest,
842+
})
843+
if err != nil {
844+
return types.BlobInfo{}, "", fmt.Errorf("trying to reuse blob %s at destination: %w", srcInfo.Digest, err)
845+
}
759846
}
760847
if reused {
761848
logrus.Debugf("Skipping blob %s (already present):", srcInfo.Digest)

image/copy/single_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,22 @@ func TestComputeDiffID(t *testing.T) {
159159
_, err = computeDiffID(reader, nil)
160160
assert.Error(t, err)
161161
}
162+
163+
func TestBrokenSetForceDestinationDigestAlgorithm(t *testing.T) {
164+
opts := &Options{}
165+
166+
// First call should succeed
167+
err := opts.BrokenSetForceDestinationDigestAlgorithm(digest.SHA256)
168+
require.NoError(t, err)
169+
170+
// Second call should fail because digest options are already configured
171+
err = opts.BrokenSetForceDestinationDigestAlgorithm(digest.SHA512)
172+
assert.Error(t, err)
173+
assert.Contains(t, err.Error(), "digest options are already configured")
174+
175+
// Test with unavailable algorithm
176+
opts2 := &Options{}
177+
err = opts2.BrokenSetForceDestinationDigestAlgorithm("sha999")
178+
assert.Error(t, err)
179+
assert.Contains(t, err.Error(), "is not available")
180+
}

image/internal/image/docker_schema1.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,8 @@ func (m *manifestSchema1) SupportsEncryption(context.Context) bool {
255255
func (m *manifestSchema1) CanChangeLayerCompression(mimeType string) bool {
256256
return true // There are no MIME types in the manifest, so we must assume a valid image.
257257
}
258+
259+
// UpdateConfigDigest updates the config descriptor's digest in the manifest.
260+
func (m *manifestSchema1) UpdateConfigDigest(newDigest digest.Digest) error {
261+
return fmt.Errorf("cannot update config digest for schema1 manifest")
262+
}

image/internal/image/docker_schema2.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,3 +412,8 @@ func (m *manifestSchema2) SupportsEncryption(context.Context) bool {
412412
func (m *manifestSchema2) CanChangeLayerCompression(mimeType string) bool {
413413
return m.m.CanChangeLayerCompression(mimeType)
414414
}
415+
416+
// UpdateConfigDigest updates the config descriptor's digest in the manifest.
417+
func (m *manifestSchema2) UpdateConfigDigest(newDigest digest.Digest) error {
418+
return m.m.UpdateConfigDigest(newDigest)
419+
}

image/internal/image/manifest.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66

7+
"github.com/opencontainers/go-digest"
78
imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
89
"go.podman.io/image/v5/docker/reference"
910
"go.podman.io/image/v5/manifest"
@@ -59,6 +60,9 @@ type genericManifest interface {
5960
// algorithms depends not on the current format, but possibly on the target of a conversion (if UpdatedImage converts
6061
// to a different manifest format).
6162
CanChangeLayerCompression(mimeType string) bool
63+
// UpdateConfigDigest updates the config descriptor's digest in the manifest.
64+
// This returns an error if the manifest does not support config digest updates (e.g., schema1 manifests).
65+
UpdateConfigDigest(newDigest digest.Digest) error
6266
}
6367

6468
// manifestInstanceFromBlob returns a genericManifest implementation for (manblob, mt) in src.

image/internal/image/oci.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"slices"
99

1010
ociencspec "github.com/containers/ocicrypt/spec"
11+
"github.com/opencontainers/go-digest"
1112
imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
1213
"go.podman.io/image/v5/docker/reference"
1314
"go.podman.io/image/v5/internal/iolimits"
@@ -332,3 +333,8 @@ func (m *manifestOCI1) SupportsEncryption(context.Context) bool {
332333
func (m *manifestOCI1) CanChangeLayerCompression(mimeType string) bool {
333334
return m.m.CanChangeLayerCompression(mimeType)
334335
}
336+
337+
// UpdateConfigDigest updates the config descriptor's digest in the manifest.
338+
func (m *manifestOCI1) UpdateConfigDigest(newDigest digest.Digest) error {
339+
return m.m.UpdateConfigDigest(newDigest)
340+
}

image/manifest/docker_schema1.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,11 @@ func (m *Schema1) UpdateLayerInfos(layerInfos []types.BlobInfo) error {
176176
return nil
177177
}
178178

179+
// UpdateConfigDigest updates the config descriptor's digest in the manifest.
180+
func (m *Schema1) UpdateConfigDigest(newDigest digest.Digest) error {
181+
return fmt.Errorf("cannot update config digest for schema1 manifest")
182+
}
183+
179184
// Serialize returns the manifest in a blob format.
180185
// NOTE: Serialize() does not in general reproduce the original blob if this object was loaded from one, even if no modifications were made!
181186
func (m *Schema1) Serialize() ([]byte, error) {

0 commit comments

Comments
 (0)