@@ -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 instanceOpKind int
@@ -29,6 +33,14 @@ const (
2933 instanceOpDelete
3034)
3135
36+ // copiedInstanceData stores info about a successfully copied instance,
37+ // used for creating sentinel variants.
38+ type copiedInstanceData struct {
39+ sourceDigest digest.Digest
40+ result copySingleImageResult
41+ platform * imgspecv1.Platform
42+ }
43+
3244type instanceOp struct {
3345 op instanceOpKind
3446 sourceDigest digest.Digest
@@ -311,6 +323,8 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
311323 if err != nil {
312324 return nil , fmt .Errorf ("preparing instances for copy: %w" , err )
313325 }
326+ var copiedInstances []copiedInstanceData
327+
314328 c .Printf ("Copying %d images generated from %d images in list\n " , copyLen , len (instanceDigests ))
315329 copyCount := 0 // Track copy/clone operations separately from delete operations
316330 for i , instance := range instanceOpList {
@@ -335,6 +349,15 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
335349 UpdateCompressionAlgorithms : updated .compressionAlgorithms ,
336350 UpdateMediaType : updated .manifestMIMEType ,
337351 })
352+ // Capture instance data for sentinel variant creation
353+ instanceDetails , detailsErr := updatedList .Instance (instance .sourceDigest )
354+ if detailsErr == nil {
355+ copiedInstances = append (copiedInstances , copiedInstanceData {
356+ sourceDigest : instance .sourceDigest ,
357+ result : updated ,
358+ platform : instanceDetails .ReadOnly .Platform ,
359+ })
360+ }
338361 case instanceOpClone :
339362 copyCount ++
340363 logrus .Debugf ("Replicating instance %s (%d/%d)" , instance .sourceDigest , copyCount , copyLen )
@@ -369,6 +392,15 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
369392 }
370393 }
371394
395+ // Create zstd:chunked sentinel variants for instances where any layer uses zstd:chunked.
396+ if cannotModifyManifestListReason == "" {
397+ sentinelEdits , err := c .createZstdChunkedSentinelVariants (ctx , copiedInstances , updatedList )
398+ if err != nil {
399+ return nil , fmt .Errorf ("creating zstd:chunked sentinel variants: %w" , err )
400+ }
401+ instanceEdits = append (instanceEdits , sentinelEdits ... )
402+ }
403+
372404 // Now reset the digest/size/types of the manifests in the list and remove deleted instances.
373405 if err = updatedList .EditInstances (instanceEdits , cannotModifyManifestListReason != "" ); err != nil {
374406 return nil , fmt .Errorf ("updating manifest list: %w" , err )
@@ -441,3 +473,248 @@ func (c *copier) copyMultipleImages(ctx context.Context) (copiedManifest []byte,
441473
442474 return manifestList , nil
443475}
476+
477+ // hasZstdChunkedLayers returns true if any non-empty layer in the manifest has
478+ // zstd:chunked TOC annotations.
479+ func hasZstdChunkedLayers (ociMan * manifest.OCI1 ) bool {
480+ for _ , l := range ociMan .LayerInfos () {
481+ if l .EmptyLayer {
482+ continue
483+ }
484+ d , err := chunkedToc .GetTOCDigest (l .Annotations )
485+ if err == nil && d != nil {
486+ return true
487+ }
488+ }
489+ return false
490+ }
491+
492+ // pushSentinelVariant creates and pushes a sentinel variant of the given OCI manifest.
493+ // It prepends a sentinel layer and DiffID, creates a new config, and pushes everything
494+ // to the destination. Returns the serialized sentinel manifest and its digest.
495+ func (c * copier ) pushSentinelVariant (ctx context.Context , ociMan * manifest.OCI1 , ociConfig * imgspecv1.Image ) ([]byte , digest.Digest , error ) {
496+ sentinelContent := chunkedToc .ZstdChunkedSentinelContent
497+
498+ // Push sentinel blob (content-addressed, so idempotent if already pushed).
499+ sentinelBlobInfo := types.BlobInfo {
500+ Digest : chunkedToc .ZstdChunkedSentinelDigest ,
501+ Size : int64 (len (sentinelContent )),
502+ }
503+ reused , _ , err := c .dest .TryReusingBlobWithOptions (ctx , sentinelBlobInfo ,
504+ private.TryReusingBlobOptions {Cache : c .blobInfoCache })
505+ if err != nil {
506+ return nil , "" , fmt .Errorf ("checking sentinel blob: %w" , err )
507+ }
508+ if ! reused {
509+ _ , err = c .dest .PutBlobWithOptions (ctx , bytes .NewReader (sentinelContent ),
510+ sentinelBlobInfo , private.PutBlobOptions {Cache : c .blobInfoCache })
511+ if err != nil {
512+ return nil , "" , fmt .Errorf ("pushing sentinel blob: %w" , err )
513+ }
514+ }
515+
516+ // Create new config with sentinel DiffID prepended.
517+ newDiffIDs := make ([]digest.Digest , 0 , len (ociConfig .RootFS .DiffIDs )+ 1 )
518+ newDiffIDs = append (newDiffIDs , chunkedToc .ZstdChunkedSentinelDigest )
519+ newDiffIDs = append (newDiffIDs , ociConfig .RootFS .DiffIDs ... )
520+ ociConfig .RootFS .DiffIDs = newDiffIDs
521+ configBlob , err := json .Marshal (ociConfig )
522+ if err != nil {
523+ return nil , "" , fmt .Errorf ("marshaling sentinel config: %w" , err )
524+ }
525+ configDigest := digest .FromBytes (configBlob )
526+
527+ // Push new config.
528+ configBlobInfo := types.BlobInfo {
529+ Digest : configDigest ,
530+ Size : int64 (len (configBlob )),
531+ MediaType : imgspecv1 .MediaTypeImageConfig ,
532+ }
533+ reused , _ , err = c .dest .TryReusingBlobWithOptions (ctx , configBlobInfo ,
534+ private.TryReusingBlobOptions {Cache : c .blobInfoCache })
535+ if err != nil {
536+ return nil , "" , fmt .Errorf ("checking sentinel config: %w" , err )
537+ }
538+ if ! reused {
539+ _ , err = c .dest .PutBlobWithOptions (ctx , bytes .NewReader (configBlob ),
540+ configBlobInfo , private.PutBlobOptions {Cache : c .blobInfoCache , IsConfig : true })
541+ if err != nil {
542+ return nil , "" , fmt .Errorf ("pushing sentinel config: %w" , err )
543+ }
544+ }
545+
546+ // Build sentinel manifest: sentinel layer + original layers.
547+ newLayers := make ([]imgspecv1.Descriptor , 0 , len (ociMan .Layers )+ 1 )
548+ newLayers = append (newLayers , imgspecv1.Descriptor {
549+ MediaType : chunkedToc .ZstdChunkedSentinelMediaType ,
550+ Digest : chunkedToc .ZstdChunkedSentinelDigest ,
551+ Size : int64 (len (sentinelContent )),
552+ })
553+ newLayers = append (newLayers , ociMan .Layers ... )
554+
555+ sentinelOCI := manifest .OCI1FromComponents (imgspecv1.Descriptor {
556+ MediaType : imgspecv1 .MediaTypeImageConfig ,
557+ Digest : configDigest ,
558+ Size : int64 (len (configBlob )),
559+ }, newLayers )
560+ sentinelManifestBlob , err := sentinelOCI .Serialize ()
561+ if err != nil {
562+ return nil , "" , fmt .Errorf ("serializing sentinel manifest: %w" , err )
563+ }
564+ sentinelManifestDigest := digest .FromBytes (sentinelManifestBlob )
565+
566+ // Push sentinel manifest.
567+ if err := c .dest .PutManifest (ctx , sentinelManifestBlob , & sentinelManifestDigest ); err != nil {
568+ return nil , "" , fmt .Errorf ("pushing sentinel manifest: %w" , err )
569+ }
570+
571+ return sentinelManifestBlob , sentinelManifestDigest , nil
572+ }
573+
574+ // createZstdChunkedSentinelVariants creates sentinel variants for instances
575+ // where any layer uses zstd:chunked. The sentinel variant has a non-tar sentinel
576+ // layer prepended, signaling aware clients to skip the full-digest mitigation.
577+ // It returns ListEdit entries to add the sentinel variants to the manifest list.
578+ func (c * copier ) createZstdChunkedSentinelVariants (ctx context.Context , copiedInstances []copiedInstanceData , updatedList internalManifest.List ) ([]internalManifest.ListEdit , error ) {
579+ var edits []internalManifest.ListEdit
580+
581+ // Check which platforms already have a sentinel variant in the source.
582+ platformsWithSentinel := set .New [platformComparable ]()
583+ for _ , d := range updatedList .Instances () {
584+ details , err := updatedList .Instance (d )
585+ if err != nil {
586+ continue
587+ }
588+ if details .ReadOnly .Annotations [internalManifest .OCI1InstanceAnnotationZstdChunkedSentinel ] == internalManifest .OCI1InstanceAnnotationZstdChunkedSentinelValue {
589+ platformsWithSentinel .Add (platformV1ToPlatformComparable (details .ReadOnly .Platform ))
590+ }
591+ }
592+
593+ for _ , ci := range copiedInstances {
594+ // Only handle OCI manifests (zstd:chunked is OCI-only).
595+ if ci .result .manifestMIMEType != imgspecv1 .MediaTypeImageManifest {
596+ continue
597+ }
598+
599+ // Skip if this platform already has a sentinel variant.
600+ if platformsWithSentinel .Contains (platformV1ToPlatformComparable (ci .platform )) {
601+ continue
602+ }
603+
604+ ociMan , err := manifest .OCI1FromManifest (ci .result .manifest )
605+ if err != nil {
606+ logrus .Debugf ("Cannot parse manifest for sentinel variant: %v" , err )
607+ continue
608+ }
609+
610+ if ! hasZstdChunkedLayers (ociMan ) {
611+ continue
612+ }
613+
614+ // Use the config as written to the destination (reflects any edits during copy).
615+ var ociConfig imgspecv1.Image
616+ if err := json .Unmarshal (ci .result .configBlob , & ociConfig ); err != nil {
617+ logrus .Debugf ("Cannot parse config for sentinel variant: %v" , err )
618+ continue
619+ }
620+
621+ sentinelManifestBlob , sentinelManifestDigest , err := c .pushSentinelVariant (ctx , ociMan , & ociConfig )
622+ if err != nil {
623+ return nil , err
624+ }
625+
626+ edits = append (edits , internalManifest.ListEdit {
627+ ListOperation : internalManifest .ListOpAdd ,
628+ AddDigest : sentinelManifestDigest ,
629+ AddSize : int64 (len (sentinelManifestBlob )),
630+ AddMediaType : imgspecv1 .MediaTypeImageManifest ,
631+ AddPlatform : ci .platform ,
632+ AddAnnotations : map [string ]string {
633+ internalManifest .OCI1InstanceAnnotationZstdChunkedSentinel : internalManifest .OCI1InstanceAnnotationZstdChunkedSentinelValue ,
634+ internalManifest .OCI1InstanceAnnotationCompressionZSTD : internalManifest .OCI1InstanceAnnotationCompressionZSTDValue ,
635+ },
636+ AddCompressionAlgorithms : ci .result .compressionAlgorithms ,
637+ })
638+
639+ platformsWithSentinel .Add (platformV1ToPlatformComparable (ci .platform ))
640+ }
641+
642+ return edits , nil
643+ }
644+
645+ // createSingleImageSentinelIndex checks if a single copied image has zstd:chunked
646+ // layers, and if so, creates a sentinel variant and wraps both in an OCI index.
647+ // Returns the serialized index manifest, or nil if no sentinel was needed.
648+ func (c * copier ) createSingleImageSentinelIndex (ctx context.Context , single copySingleImageResult ) ([]byte , error ) {
649+ if single .manifestMIMEType != imgspecv1 .MediaTypeImageManifest {
650+ return nil , nil
651+ }
652+
653+ ociMan , err := manifest .OCI1FromManifest (single .manifest )
654+ if err != nil {
655+ return nil , nil
656+ }
657+
658+ if ! hasZstdChunkedLayers (ociMan ) {
659+ return nil , nil
660+ }
661+
662+ // Use the config as written to the destination (reflects any edits during copy).
663+ var ociConfig imgspecv1.Image
664+ if err := json .Unmarshal (single .configBlob , & ociConfig ); err != nil {
665+ logrus .Debugf ("Cannot parse config for single-image sentinel: %v" , err )
666+ return nil , nil
667+ }
668+
669+ sentinelManifestBlob , sentinelManifestDigest , err := c .pushSentinelVariant (ctx , ociMan , & ociConfig )
670+ if err != nil {
671+ return nil , err
672+ }
673+
674+ // Extract platform from config.
675+ var platform * imgspecv1.Platform
676+ if ociConfig .OS != "" || ociConfig .Architecture != "" {
677+ platform = & imgspecv1.Platform {
678+ OS : ociConfig .OS ,
679+ Architecture : ociConfig .Architecture ,
680+ Variant : ociConfig .Variant ,
681+ OSVersion : ociConfig .OSVersion ,
682+ }
683+ }
684+
685+ // Build OCI index with: original manifest first, sentinel variant last.
686+ // Both entries get the zstd annotation so that old clients (which prefer
687+ // zstd over gzip) fall through to position-based selection and pick the
688+ // original at position 0 instead of the sentinel.
689+ index := manifest .OCI1IndexFromComponents ([]imgspecv1.Descriptor {
690+ {
691+ MediaType : imgspecv1 .MediaTypeImageManifest ,
692+ Digest : single .manifestDigest ,
693+ Size : int64 (len (single .manifest )),
694+ Platform : platform ,
695+ Annotations : map [string ]string {
696+ internalManifest .OCI1InstanceAnnotationCompressionZSTD : internalManifest .OCI1InstanceAnnotationCompressionZSTDValue ,
697+ },
698+ },
699+ {
700+ MediaType : imgspecv1 .MediaTypeImageManifest ,
701+ Digest : sentinelManifestDigest ,
702+ Size : int64 (len (sentinelManifestBlob )),
703+ Platform : platform ,
704+ Annotations : map [string ]string {
705+ internalManifest .OCI1InstanceAnnotationZstdChunkedSentinel : internalManifest .OCI1InstanceAnnotationZstdChunkedSentinelValue ,
706+ internalManifest .OCI1InstanceAnnotationCompressionZSTD : internalManifest .OCI1InstanceAnnotationCompressionZSTDValue ,
707+ },
708+ },
709+ }, nil )
710+ indexBlob , err := index .Serialize ()
711+ if err != nil {
712+ return nil , fmt .Errorf ("serializing sentinel index: %w" , err )
713+ }
714+
715+ if err := c .dest .PutManifest (ctx , indexBlob , nil ); err != nil {
716+ return nil , fmt .Errorf ("pushing sentinel index: %w" , err )
717+ }
718+
719+ return indexBlob , nil
720+ }
0 commit comments