Skip to content
10 changes: 9 additions & 1 deletion image/copy/blob.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ func (ic *imageCopier) copyBlobFromStream(ctx context.Context, srcReader io.Read
Cache: ic.c.blobInfoCache,
IsConfig: isConfig,
EmptyLayer: emptyLayer,
Digests: ic.c.options.digestOptions,
// CannotChangeDigestReason requires stream.info.Digest to always be set, and it is:
// If ic.cannotModifyManifestReason, stream.info was not modified since its initialization at the top of this
// function, and the caller is required to provide a digest.
CannotChangeDigestReason: ic.cannotModifyManifestReason,
}
if !isConfig {
options.LayerIndex = &layerIndex
Expand Down Expand Up @@ -133,7 +138,10 @@ func (ic *imageCopier) copyBlobFromStream(ctx context.Context, srcReader io.Read
return types.BlobInfo{}, fmt.Errorf("Internal error writing blob %s, digest verification failed but was ignored", srcInfo.Digest)
}
if stream.info.Digest != "" && uploadedInfo.Digest != stream.info.Digest {
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)
// If algorithms match, the whole digest values must match
if stream.info.Digest.Algorithm() == uploadedInfo.Digest.Algorithm() {
Comment on lines 140 to +142
Copy link
Contributor

Choose a reason for hiding this comment

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

Conceptually, this goes:

  • If stream.Info.Digest is set (otherwise N/A)
  • … and if the algorithms match (otherwise the comparison is meaningless)
  • then the full values should match.

Sure, it’s all within effectively a series of &&, so the ultimate behavior is the same either way, but I think it’s a good habit to only consume values when they are known to exist and to be relevant.

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)
}
}
if digestingReader.validationSucceeded {
if err := compressionStep.recordValidatedDigestData(ic.c, uploadedInfo, srcInfo, encryptionStep, decryptionStep); err != nil {
Expand Down
37 changes: 37 additions & 0 deletions image/copy/copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/sirupsen/logrus"
"go.podman.io/image/v5/docker/reference"
internalblobinfocache "go.podman.io/image/v5/internal/blobinfocache"
"go.podman.io/image/v5/internal/digests"
"go.podman.io/image/v5/internal/image"
"go.podman.io/image/v5/internal/imagedestination"
"go.podman.io/image/v5/internal/imagesource"
Expand Down Expand Up @@ -155,6 +156,35 @@ type Options struct {
// In oci-archive: destinations, this will set the create/mod/access timestamps in each tar entry
// (but not a timestamp of the created archive file).
DestinationTimestamp *time.Time

// FIXME:
// - this reference to an internal type is unusable from the outside even if we made the field public
// - what is the actual semantics? Right now it is probably “choices to use when writing to the destination”, TBD
// - anyway do we want to expose _all_ of the digests.Options tunables, or fewer?
// - … do we want to expose _more_ granularity than that?
// - (“must have at least sha512 integrity when reading”, what does “at least” mean for random pairs of algorithms?)
// - should some of this be in config files, maybe ever per-registry?
digestOptions digests.Options
}

// BrokenSetForceDestinationDigestAlgorithm forces the use of a specific digest algorithm when writing to the destination.
//
// UNSTABLE API: This API is incomplete and may be changed or removed at any time.
// It currently only enforces the digest algorithm for a subset of transports and operations.
// See https://github.com/containers/container-libs/pull/552 for implementation status.
func (o *Options) BrokenSetForceDestinationDigestAlgorithm(algo digest.Algorithm) error {
if o.digestOptions.MustUseSet() != "" {
Copy link
Contributor

Choose a reason for hiding this comment

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

if o.digestOptions != digests.Options{} would be more general for the future — if some part of a caller is setting any of the options using some future API, this one should not entirely overwrite that configuration.

return fmt.Errorf("digest options are already configured")
}
if !algo.Available() {
return fmt.Errorf("digest algorithm %q is not available", algo.String())
}
Comment on lines +179 to +181
Copy link
Contributor

Choose a reason for hiding this comment

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

digests.MustUse already contains this check.

digestOpts, err := digests.MustUse(algo)
if err != nil {
return fmt.Errorf("failed to set force-digest algorithm: %w", err)
}
o.digestOptions = digestOpts
return nil
}

// OptionCompressionVariant allows to supply information about
Expand Down Expand Up @@ -200,6 +230,13 @@ func Image(ctx context.Context, policyContext *signature.PolicyContext, destRef,
if options == nil {
options = &Options{}
}
// FIXME: digestsOptions exists to gradually build the feature. Provide public API once fully implemented.
// Set default only if not configured by BrokenSetForceDestinationDigestAlgorithm
if options.digestOptions.MustUseSet() == "" {
Copy link
Contributor

Choose a reason for hiding this comment

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

digestOptions != digests.Options{}

optionsCopy := *options
optionsCopy.digestOptions = digests.CanonicalDefault()
options = &optionsCopy
}

if err := validateImageListSelection(options.ImageListSelection); err != nil {
return nil, err
Expand Down
6 changes: 6 additions & 0 deletions image/copy/multiple.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,12 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
cannotModifyManifestListReason = "Instructed to preserve digests"
}

// FIXME: Multi-arch not supported yet; would need config digest updates for each instance.
// See https://github.com/containers/container-libs/pull/552#discussion_r2611627578
if c.options.digestOptions.MustUseSet() != "" {
Copy link
Contributor

Choose a reason for hiding this comment

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

This can happen at the very top of the function — and it should check for any non-default digestOptions.

return nil, fmt.Errorf("forcing digest algorithm with multi-arch images is not yet implemented")
}

// Determine if we'll need to convert the manifest list to a different format.
forceListMIMEType := c.options.ForceManifestMIMEType
switch forceListMIMEType {
Expand Down
95 changes: 75 additions & 20 deletions image/copy/single.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/sirupsen/logrus"
"github.com/vbauerster/mpb/v8"
"go.podman.io/image/v5/docker/reference"
"go.podman.io/image/v5/internal/digests"
"go.podman.io/image/v5/internal/image"
"go.podman.io/image/v5/internal/pkg/platform"
"go.podman.io/image/v5/internal/private"
Expand Down Expand Up @@ -592,12 +593,27 @@ func (ic *imageCopier) copyUpdatedConfigAndManifest(ctx context.Context, instanc
return nil, "", fmt.Errorf("reading manifest: %w", err)
}

if err := ic.copyConfig(ctx, pendingImage); err != nil {
newConfigDigest, err := ic.copyConfig(ctx, pendingImage)
if err != nil {
return nil, "", err
}

// FIXME: Single image only; multi-arch needs per-instance config digest updates.
// See https://github.com/containers/container-libs/pull/552#discussion_r2611627578
Copy link
Contributor

Choose a reason for hiding this comment

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

I don’t see how this comment matches this code. (And, below, there is manifestDigestAlgo already trying to handle that…)

if newConfigDigest != nil {
man, err = ic.updateManifestConfigDigest(man, pendingImage, *newConfigDigest)
Copy link
Contributor

Choose a reason for hiding this comment

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

The way updateManifestConfigDigest etc. works, by first serializing the in-memory data into man a bit above, then parsing it here, updating, and serializing again, is unnecessarily costly.

Instead, we can still edit pendingImage, and serialize into man only after this edit:

(Also, conceptually — we have types.Image.UpdatedImage with ManifestUpdateOptions.LayerInfos, so adding ManifestUpdateOptions.ConfigInfo *types.BlobInfo would be symmetrical, and useful for external callers.)

manifestUpdatesForConfig, err := ic.copyConfig() // see elsewhere about closely co-locating the code that changes values and that arranges for manifest updates

if ! /* generalization of noPendingManifestUpdates */(manifestUpdatesForConfig) {
    if ic.cannotModifyManifestReason != "" { /* fail, just to be sure */ }
    pendingImage, err = pendingImage.UpdatedImage(ctx, manifestUpdatesForConfig)
}

man, _, err := pendingImage.Manifest(ctx)

And to implement that, genericManifest.UpdateConfigDigest does not need to exist — add a ConfigInfo field to ManifestUpdateOptions and implement it by calling the manifests’ UpdateConfigInfo.

if err != nil {
return nil, "", fmt.Errorf("updating manifest config digest: %w", err)
}
}

ic.c.Printf("Writing manifest to image destination\n")
manifestDigest, err := manifest.Digest(man)
// Choose the digest algorithm based on digest options
manifestDigestAlgo, err := ic.c.options.digestOptions.Choose(digests.Situation{})
if err != nil {
return nil, "", fmt.Errorf("choosing manifest digest algorithm: %w", err)
}
manifestDigest, err := manifest.DigestWithAlgorithm(man, manifestDigestAlgo)
Comment on lines +611 to +616
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️

This probably doesn’t work in general — with a sha256-only transport (e.g. an old registry), we would successfully upload the manifest, compute a sha512 digest, put the sha512 digest into a manifest list — but the sha512 digest would not be resolvable by the transport.

So we will need some cooperation with the transport to choose a digest, that’s easy enough (private.ImageDestination.PutManifestWithOptions(ctx, man, PutManifestOptions{IsNotTopLevel: instanceDigest != nil, Digests: …, CannotChangeManifestReason: …} and have that new method choose an algorithm and compute it. (Changing how we do IsNotTopLevel so that we don’t have to know the instanceDigest value in advance.)


The ⚠️ part is that for registries, we will need a “does the registry allow referring to manifests via $algorithm” API, and I’m not sure whether that is in scope of the existing proposals; we might need to raise that.

Copy link
Contributor

Choose a reason for hiding this comment

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

I have noted some of the registry concerns in https://issues.redhat.com/browse/PROJQUAY-10117 ; there is also pre-existing upstream discussion in opencontainers/distribution-spec#543 and opencontainers/distribution-spec#494 (although I didn’t carefully read either of them).

Copy link
Contributor

Choose a reason for hiding this comment

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

if err != nil {
return nil, "", err
}
Expand All @@ -611,13 +627,33 @@ func (ic *imageCopier) copyUpdatedConfigAndManifest(ctx context.Context, instanc
return man, manifestDigest, nil
}

// updateManifestConfigDigest updates the config digest in the manifest using the manifest abstraction layer.
func (ic *imageCopier) updateManifestConfigDigest(manifestBlob []byte, src types.Image, newConfigDigest digest.Digest) ([]byte, error) {
_, mt, err := src.Manifest(context.Background())
if err != nil {
return nil, fmt.Errorf("getting manifest type: %w", err)
}

m, err := manifest.FromBlob(manifestBlob, mt)
if err != nil {
return nil, fmt.Errorf("parsing manifest: %w", err)
}

if err := m.UpdateConfigDigest(newConfigDigest); err != nil {
return nil, err
}

return m.Serialize()
}

// copyConfig copies config.json, if any, from src to dest.
func (ic *imageCopier) copyConfig(ctx context.Context, src types.Image) error {
// It returns the new config digest if it changed (due to digest algorithm forcing), or nil otherwise.
func (ic *imageCopier) copyConfig(ctx context.Context, src types.Image) (*digest.Digest, error) {
srcInfo := src.ConfigInfo()
if srcInfo.Digest != "" {
if err := ic.c.concurrentBlobCopiesSemaphore.Acquire(ctx, 1); err != nil {
// This can only fail with ctx.Err(), so no need to blame acquiring the semaphore.
return fmt.Errorf("copying config: %w", err)
return nil, fmt.Errorf("copying config: %w", err)
}
defer ic.c.concurrentBlobCopiesSemaphore.Release(1)

Expand Down Expand Up @@ -645,13 +681,17 @@ func (ic *imageCopier) copyConfig(ctx context.Context, src types.Image) error {
return destInfo, nil
}()
if err != nil {
return err
return nil, err
}
if destInfo.Digest != srcInfo.Digest {
return fmt.Errorf("Internal error: copying uncompressed config blob %s changed digest to %s", srcInfo.Digest, destInfo.Digest)
// If algorithms match, the whole digest values must match
if destInfo.Digest.Algorithm() == srcInfo.Digest.Algorithm() {
return nil, fmt.Errorf("Internal error: copying uncompressed config blob %s changed digest to %s", srcInfo.Digest, destInfo.Digest)
}
return &destInfo.Digest, nil
}
}
return nil
return nil, nil
}

// diffIDResult contains both a digest value and an error from diffIDComputationGoroutine.
Expand Down Expand Up @@ -743,19 +783,34 @@ func (ic *imageCopier) copyLayer(ctx context.Context, srcInfo types.BlobInfo, to
tocDigest = *d
}

reused, reusedBlob, err := ic.c.dest.TryReusingBlobWithOptions(ctx, srcInfo, private.TryReusingBlobOptions{
Cache: ic.c.blobInfoCache,
CanSubstitute: canSubstitute,
EmptyLayer: emptyLayer,
LayerIndex: &layerIndex,
SrcRef: srcRef,
PossibleManifestFormats: append([]string{ic.manifestConversionPlan.preferredMIMEType}, ic.manifestConversionPlan.otherMIMETypeCandidates...),
RequiredCompression: requiredCompression,
OriginalCompression: srcInfo.CompressionAlgorithm,
TOCDigest: tocDigest,
})
if err != nil {
return types.BlobInfo{}, "", fmt.Errorf("trying to reuse blob %s at destination: %w", srcInfo.Digest, err)
// FIXME: Blob reuse disabled when forcing different digest algorithm.
canTryReuse := true
if forcedAlgo := ic.c.options.digestOptions.MustUseSet(); forcedAlgo != "" {
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this might check for “is not the default digestOptions” (not feeling too strongly about this) — and definitely Warnf if we skip this important performance aspect.

if srcInfo.Digest.Algorithm() != forcedAlgo {
logrus.Debugf("Skipping blob reuse for %s: digest algorithm %s doesn't match forced algorithm %s",
srcInfo.Digest, srcInfo.Digest.Algorithm(), forcedAlgo)
canTryReuse = false
Copy link
Contributor

Choose a reason for hiding this comment

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

This should probably also check tocDigest.

}
}

reused := false
var reusedBlob private.ReusedBlob
if canTryReuse {
var err error
reused, reusedBlob, err = ic.c.dest.TryReusingBlobWithOptions(ctx, srcInfo, private.TryReusingBlobOptions{
Cache: ic.c.blobInfoCache,
CanSubstitute: canSubstitute,
EmptyLayer: emptyLayer,
LayerIndex: &layerIndex,
SrcRef: srcRef,
PossibleManifestFormats: append([]string{ic.manifestConversionPlan.preferredMIMEType}, ic.manifestConversionPlan.otherMIMETypeCandidates...),
RequiredCompression: requiredCompression,
OriginalCompression: srcInfo.CompressionAlgorithm,
TOCDigest: tocDigest,
})
if err != nil {
return types.BlobInfo{}, "", fmt.Errorf("trying to reuse blob %s at destination: %w", srcInfo.Digest, err)
}
}
if reused {
logrus.Debugf("Skipping blob %s (already present):", srcInfo.Digest)
Expand Down
19 changes: 19 additions & 0 deletions image/copy/single_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,22 @@ func TestComputeDiffID(t *testing.T) {
_, err = computeDiffID(reader, nil)
assert.Error(t, err)
}

func TestBrokenSetForceDestinationDigestAlgorithm(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This belongs to copy_test.go, and TestOptionsBroken… to match the convention.

opts := &Options{}

// First call should succeed
err := opts.BrokenSetForceDestinationDigestAlgorithm(digest.SHA256)
require.NoError(t, err)

// Second call should fail because digest options are already configured
err = opts.BrokenSetForceDestinationDigestAlgorithm(digest.SHA512)
assert.Error(t, err)
assert.Contains(t, err.Error(), "digest options are already configured")

// Test with unavailable algorithm
opts2 := &Options{}
err = opts2.BrokenSetForceDestinationDigestAlgorithm("sha999")
assert.Error(t, err)
assert.Contains(t, err.Error(), "is not available")
}
7 changes: 6 additions & 1 deletion image/directory/directory_dest.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/opencontainers/go-digest"
"github.com/sirupsen/logrus"
"go.podman.io/image/v5/internal/digests"
"go.podman.io/image/v5/internal/imagedestination/impl"
"go.podman.io/image/v5/internal/imagedestination/stubs"
"go.podman.io/image/v5/internal/private"
Expand Down Expand Up @@ -150,7 +151,11 @@ func (d *dirImageDestination) PutBlobWithOptions(ctx context.Context, stream io.
}
}()

digester, stream := putblobdigest.DigestIfUnknown(stream, inputInfo)
algorithm, err := options.Digests.Choose(digests.Situation{Preexisting: inputInfo.Digest, CannotChangeAlgorithmReason: options.CannotChangeDigestReason})
if err != nil {
return private.UploadedBlob{}, err
}
digester, stream := putblobdigest.DigestIfAlgorithmUnknown(stream, inputInfo, algorithm)

// TODO: This can take quite some time, and should ideally be cancellable using ctx.Done().
size, err := io.Copy(blobFile, stream)
Expand Down
Loading
Loading