@@ -3,6 +3,7 @@ package copy
33import (
44 "bytes"
55 "context"
6+ "encoding/json"
67 "errors"
78 "fmt"
89 "maps"
@@ -16,9 +17,12 @@ import (
1617 "go.podman.io/image/v5/docker/reference"
1718 "go.podman.io/image/v5/internal/image"
1819 internalManifest "go.podman.io/image/v5/internal/manifest"
20+ "go.podman.io/image/v5/internal/private"
1921 "go.podman.io/image/v5/internal/set"
2022 "go.podman.io/image/v5/manifest"
2123 "go.podman.io/image/v5/pkg/compression"
24+ "go.podman.io/image/v5/types"
25+ chunkedToc "go.podman.io/storage/pkg/chunked/toc"
2226)
2327
2428type instanceCopyKind int
@@ -28,6 +32,14 @@ const (
2832 instanceCopyClone
2933)
3034
35+ // copiedInstanceData stores info about a successfully copied instance,
36+ // used for creating sentinel variants.
37+ type copiedInstanceData struct {
38+ sourceDigest digest.Digest
39+ result copySingleImageResult
40+ platform * imgspecv1.Platform
41+ }
42+
3143type instanceCopy struct {
3244 op instanceCopyKind
3345 sourceDigest digest.Digest
@@ -283,6 +295,8 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
283295 if err != nil {
284296 return nil , fmt .Errorf ("preparing instances for copy: %w" , err )
285297 }
298+ var copiedInstances []copiedInstanceData
299+
286300 c .Printf ("Copying %d images generated from %d images in list\n " , len (instanceCopyList ), len (instanceDigests ))
287301 for i , instance := range instanceCopyList {
288302 // Update instances to be edited by their `ListOperation` and
@@ -305,6 +319,15 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
305319 UpdateCompressionAlgorithms : updated .compressionAlgorithms ,
306320 UpdateMediaType : updated .manifestMIMEType ,
307321 })
322+ // Capture instance data for sentinel variant creation
323+ instanceDetails , detailsErr := updatedList .Instance (instance .sourceDigest )
324+ if detailsErr == nil {
325+ copiedInstances = append (copiedInstances , copiedInstanceData {
326+ sourceDigest : instance .sourceDigest ,
327+ result : updated ,
328+ platform : instanceDetails .ReadOnly .Platform ,
329+ })
330+ }
308331 case instanceCopyClone :
309332 logrus .Debugf ("Replicating instance %s (%d/%d)" , instance .sourceDigest , i + 1 , len (instanceCopyList ))
310333 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,
333356 }
334357 }
335358
359+ // Create zstd:chunked sentinel variants for instances where any layer uses zstd:chunked.
360+ if cannotModifyManifestListReason == "" {
361+ sentinelEdits , err := c .createZstdChunkedSentinelVariants (ctx , copiedInstances , updatedList )
362+ if err != nil {
363+ return nil , fmt .Errorf ("creating zstd:chunked sentinel variants: %w" , err )
364+ }
365+ instanceEdits = append (instanceEdits , sentinelEdits ... )
366+ }
367+
336368 // Now reset the digest/size/types of the manifests in the list to account for any conversions that we made.
337369 if err = updatedList .EditInstances (instanceEdits , cannotModifyManifestListReason != "" ); err != nil {
338370 return nil , fmt .Errorf ("updating manifest list: %w" , err )
@@ -405,3 +437,248 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
405437
406438 return manifestList , nil
407439}
440+
441+ // hasZstdChunkedLayers returns true if any non-empty layer in the manifest has
442+ // zstd:chunked TOC annotations.
443+ func hasZstdChunkedLayers (ociMan * manifest.OCI1 ) bool {
444+ for _ , l := range ociMan .LayerInfos () {
445+ if l .EmptyLayer {
446+ continue
447+ }
448+ d , err := chunkedToc .GetTOCDigest (l .Annotations )
449+ if err == nil && d != nil {
450+ return true
451+ }
452+ }
453+ return false
454+ }
455+
456+ // pushSentinelVariant creates and pushes a sentinel variant of the given OCI manifest.
457+ // It prepends a sentinel layer and DiffID, creates a new config, and pushes everything
458+ // to the destination. Returns the serialized sentinel manifest and its digest.
459+ func (c * copier ) pushSentinelVariant (ctx context.Context , ociMan * manifest.OCI1 , ociConfig * imgspecv1.Image ) ([]byte , digest.Digest , error ) {
460+ sentinelContent := chunkedToc .ZstdChunkedSentinelContent
461+
462+ // Push sentinel blob (content-addressed, so idempotent if already pushed).
463+ sentinelBlobInfo := types.BlobInfo {
464+ Digest : chunkedToc .ZstdChunkedSentinelDigest ,
465+ Size : int64 (len (sentinelContent )),
466+ }
467+ reused , _ , err := c .dest .TryReusingBlobWithOptions (ctx , sentinelBlobInfo ,
468+ private.TryReusingBlobOptions {Cache : c .blobInfoCache })
469+ if err != nil {
470+ return nil , "" , fmt .Errorf ("checking sentinel blob: %w" , err )
471+ }
472+ if ! reused {
473+ _ , err = c .dest .PutBlobWithOptions (ctx , bytes .NewReader (sentinelContent ),
474+ sentinelBlobInfo , private.PutBlobOptions {Cache : c .blobInfoCache })
475+ if err != nil {
476+ return nil , "" , fmt .Errorf ("pushing sentinel blob: %w" , err )
477+ }
478+ }
479+
480+ // Create new config with sentinel DiffID prepended.
481+ newDiffIDs := make ([]digest.Digest , 0 , len (ociConfig .RootFS .DiffIDs )+ 1 )
482+ newDiffIDs = append (newDiffIDs , chunkedToc .ZstdChunkedSentinelDigest )
483+ newDiffIDs = append (newDiffIDs , ociConfig .RootFS .DiffIDs ... )
484+ ociConfig .RootFS .DiffIDs = newDiffIDs
485+ configBlob , err := json .Marshal (ociConfig )
486+ if err != nil {
487+ return nil , "" , fmt .Errorf ("marshaling sentinel config: %w" , err )
488+ }
489+ configDigest := digest .FromBytes (configBlob )
490+
491+ // Push new config.
492+ configBlobInfo := types.BlobInfo {
493+ Digest : configDigest ,
494+ Size : int64 (len (configBlob )),
495+ MediaType : imgspecv1 .MediaTypeImageConfig ,
496+ }
497+ reused , _ , err = c .dest .TryReusingBlobWithOptions (ctx , configBlobInfo ,
498+ private.TryReusingBlobOptions {Cache : c .blobInfoCache })
499+ if err != nil {
500+ return nil , "" , fmt .Errorf ("checking sentinel config: %w" , err )
501+ }
502+ if ! reused {
503+ _ , err = c .dest .PutBlobWithOptions (ctx , bytes .NewReader (configBlob ),
504+ configBlobInfo , private.PutBlobOptions {Cache : c .blobInfoCache , IsConfig : true })
505+ if err != nil {
506+ return nil , "" , fmt .Errorf ("pushing sentinel config: %w" , err )
507+ }
508+ }
509+
510+ // Build sentinel manifest: sentinel layer + original layers.
511+ newLayers := make ([]imgspecv1.Descriptor , 0 , len (ociMan .Layers )+ 1 )
512+ newLayers = append (newLayers , imgspecv1.Descriptor {
513+ MediaType : imgspecv1 .MediaTypeImageLayerZstd ,
514+ Digest : chunkedToc .ZstdChunkedSentinelDigest ,
515+ Size : int64 (len (sentinelContent )),
516+ })
517+ newLayers = append (newLayers , ociMan .Layers ... )
518+
519+ sentinelOCI := manifest .OCI1FromComponents (imgspecv1.Descriptor {
520+ MediaType : imgspecv1 .MediaTypeImageConfig ,
521+ Digest : configDigest ,
522+ Size : int64 (len (configBlob )),
523+ }, newLayers )
524+ sentinelManifestBlob , err := sentinelOCI .Serialize ()
525+ if err != nil {
526+ return nil , "" , fmt .Errorf ("serializing sentinel manifest: %w" , err )
527+ }
528+ sentinelManifestDigest := digest .FromBytes (sentinelManifestBlob )
529+
530+ // Push sentinel manifest.
531+ if err := c .dest .PutManifest (ctx , sentinelManifestBlob , & sentinelManifestDigest ); err != nil {
532+ return nil , "" , fmt .Errorf ("pushing sentinel manifest: %w" , err )
533+ }
534+
535+ return sentinelManifestBlob , sentinelManifestDigest , nil
536+ }
537+
538+ // createZstdChunkedSentinelVariants creates sentinel variants for instances
539+ // where any layer uses zstd:chunked. The sentinel variant has a non-tar sentinel
540+ // layer prepended, signaling aware clients to skip the full-digest mitigation.
541+ // It returns ListEdit entries to add the sentinel variants to the manifest list.
542+ func (c * copier ) createZstdChunkedSentinelVariants (ctx context.Context , copiedInstances []copiedInstanceData , updatedList internalManifest.List ) ([]internalManifest.ListEdit , error ) {
543+ var edits []internalManifest.ListEdit
544+
545+ // Check which platforms already have a sentinel variant in the source.
546+ platformsWithSentinel := set .New [platformComparable ]()
547+ for _ , d := range updatedList .Instances () {
548+ details , err := updatedList .Instance (d )
549+ if err != nil {
550+ continue
551+ }
552+ if details .ReadOnly .Annotations [internalManifest .OCI1InstanceAnnotationZstdChunkedSentinel ] == internalManifest .OCI1InstanceAnnotationZstdChunkedSentinelValue {
553+ platformsWithSentinel .Add (platformV1ToPlatformComparable (details .ReadOnly .Platform ))
554+ }
555+ }
556+
557+ for _ , ci := range copiedInstances {
558+ // Only handle OCI manifests (zstd:chunked is OCI-only).
559+ if ci .result .manifestMIMEType != imgspecv1 .MediaTypeImageManifest {
560+ continue
561+ }
562+
563+ // Skip if this platform already has a sentinel variant.
564+ if platformsWithSentinel .Contains (platformV1ToPlatformComparable (ci .platform )) {
565+ continue
566+ }
567+
568+ ociMan , err := manifest .OCI1FromManifest (ci .result .manifest )
569+ if err != nil {
570+ logrus .Debugf ("Cannot parse manifest for sentinel variant: %v" , err )
571+ continue
572+ }
573+
574+ if ! hasZstdChunkedLayers (ociMan ) {
575+ continue
576+ }
577+
578+ // Use the config as written to the destination (reflects any edits during copy).
579+ var ociConfig imgspecv1.Image
580+ if err := json .Unmarshal (ci .result .configBlob , & ociConfig ); err != nil {
581+ logrus .Debugf ("Cannot parse config for sentinel variant: %v" , err )
582+ continue
583+ }
584+
585+ sentinelManifestBlob , sentinelManifestDigest , err := c .pushSentinelVariant (ctx , ociMan , & ociConfig )
586+ if err != nil {
587+ return nil , err
588+ }
589+
590+ edits = append (edits , internalManifest.ListEdit {
591+ ListOperation : internalManifest .ListOpAdd ,
592+ AddDigest : sentinelManifestDigest ,
593+ AddSize : int64 (len (sentinelManifestBlob )),
594+ AddMediaType : imgspecv1 .MediaTypeImageManifest ,
595+ AddPlatform : ci .platform ,
596+ AddAnnotations : map [string ]string {
597+ internalManifest .OCI1InstanceAnnotationZstdChunkedSentinel : internalManifest .OCI1InstanceAnnotationZstdChunkedSentinelValue ,
598+ internalManifest .OCI1InstanceAnnotationCompressionZSTD : internalManifest .OCI1InstanceAnnotationCompressionZSTDValue ,
599+ },
600+ AddCompressionAlgorithms : ci .result .compressionAlgorithms ,
601+ })
602+
603+ platformsWithSentinel .Add (platformV1ToPlatformComparable (ci .platform ))
604+ }
605+
606+ return edits , nil
607+ }
608+
609+ // createSingleImageSentinelIndex checks if a single copied image has zstd:chunked
610+ // layers, and if so, creates a sentinel variant and wraps both in an OCI index.
611+ // Returns the serialized index manifest, or nil if no sentinel was needed.
612+ func (c * copier ) createSingleImageSentinelIndex (ctx context.Context , single copySingleImageResult ) ([]byte , error ) {
613+ if single .manifestMIMEType != imgspecv1 .MediaTypeImageManifest {
614+ return nil , nil
615+ }
616+
617+ ociMan , err := manifest .OCI1FromManifest (single .manifest )
618+ if err != nil {
619+ return nil , nil
620+ }
621+
622+ if ! hasZstdChunkedLayers (ociMan ) {
623+ return nil , nil
624+ }
625+
626+ // Use the config as written to the destination (reflects any edits during copy).
627+ var ociConfig imgspecv1.Image
628+ if err := json .Unmarshal (single .configBlob , & ociConfig ); err != nil {
629+ logrus .Debugf ("Cannot parse config for single-image sentinel: %v" , err )
630+ return nil , nil
631+ }
632+
633+ sentinelManifestBlob , sentinelManifestDigest , err := c .pushSentinelVariant (ctx , ociMan , & ociConfig )
634+ if err != nil {
635+ return nil , err
636+ }
637+
638+ // Extract platform from config.
639+ var platform * imgspecv1.Platform
640+ if ociConfig .OS != "" || ociConfig .Architecture != "" {
641+ platform = & imgspecv1.Platform {
642+ OS : ociConfig .OS ,
643+ Architecture : ociConfig .Architecture ,
644+ Variant : ociConfig .Variant ,
645+ OSVersion : ociConfig .OSVersion ,
646+ }
647+ }
648+
649+ // Build OCI index with: original manifest first, sentinel variant last.
650+ // Both entries get the zstd annotation so that old clients (which prefer
651+ // zstd over gzip) fall through to position-based selection and pick the
652+ // original at position 0 instead of the sentinel.
653+ index := manifest .OCI1IndexFromComponents ([]imgspecv1.Descriptor {
654+ {
655+ MediaType : imgspecv1 .MediaTypeImageManifest ,
656+ Digest : single .manifestDigest ,
657+ Size : int64 (len (single .manifest )),
658+ Platform : platform ,
659+ Annotations : map [string ]string {
660+ internalManifest .OCI1InstanceAnnotationCompressionZSTD : internalManifest .OCI1InstanceAnnotationCompressionZSTDValue ,
661+ },
662+ },
663+ {
664+ MediaType : imgspecv1 .MediaTypeImageManifest ,
665+ Digest : sentinelManifestDigest ,
666+ Size : int64 (len (sentinelManifestBlob )),
667+ Platform : platform ,
668+ Annotations : map [string ]string {
669+ internalManifest .OCI1InstanceAnnotationZstdChunkedSentinel : internalManifest .OCI1InstanceAnnotationZstdChunkedSentinelValue ,
670+ internalManifest .OCI1InstanceAnnotationCompressionZSTD : internalManifest .OCI1InstanceAnnotationCompressionZSTDValue ,
671+ },
672+ },
673+ }, nil )
674+ indexBlob , err := index .Serialize ()
675+ if err != nil {
676+ return nil , fmt .Errorf ("serializing sentinel index: %w" , err )
677+ }
678+
679+ if err := c .dest .PutManifest (ctx , indexBlob , nil ); err != nil {
680+ return nil , fmt .Errorf ("pushing sentinel index: %w" , err )
681+ }
682+
683+ return indexBlob , nil
684+ }
0 commit comments