Add support for selective signature removal and sparse manifest list stripping

This commit introduces two new flags for the skopeo copy command to provide
more granular control over multi-architecture image copying:

--remove-list-signatures: Removes only the manifest list signature while
preserving per-instance signatures. This provides finer control than the
existing --remove-signatures flag which removes all signatures.

--strip-sparse-manifest-list: Strips missing instances from manifest lists
when copying only a subset of platforms (using --multi-arch with a platform
list). This is useful for registries that don't support sparse manifest lists.

The --strip-sparse-manifest-list flag requires explicit signature removal
using either --remove-signatures or --remove-list-signatures, as stripping
instances invalidates the manifest list signature.

These flags address scenarios where users need to copy multi-architecture
images to registries with different signature requirements or those that
don't support sparse manifest lists, while still maintaining control over
which signatures are preserved.

Documentation has been updated with usage examples, and integration tests
have been added to verify the new functionality.

Signed-off-by: Alex Guidi <aguidi@redhat.com>
This commit is contained in:
Alex Guidi
2026-06-08 12:20:53 +02:00
parent eb1230cbb5
commit 51566c51b2
6 changed files with 112 additions and 17 deletions

View File

@@ -20,22 +20,23 @@ import (
)
type copyOptions struct {
global *globalOptions
deprecatedTLSVerify *deprecatedTLSVerifyOption
srcImage *imageOptions
destImage *imageDestOptions
retryOpts *retry.Options
copy *sharedCopyOptions
additionalTags []string // For docker-archive: destinations, in addition to the name:tag specified as destination, also add these
signIdentity string // Identity of the signed image, must be a fully specified docker reference
digestFile string // Write digest to this file
quiet bool // Suppress output information when copying images
all bool // Copy all of the images if the source is a list
multiArch commonFlag.OptionalString // How to handle multi architecture images
encryptLayer []int // The list of layers to encrypt
encryptionKeys []string // Keys needed to encrypt the image
decryptionKeys []string // Keys needed to decrypt the image
imageParallelCopies uint // Maximum number of parallel requests when copying images
global *globalOptions
deprecatedTLSVerify *deprecatedTLSVerifyOption
srcImage *imageOptions
destImage *imageDestOptions
retryOpts *retry.Options
copy *sharedCopyOptions
additionalTags []string // For docker-archive: destinations, in addition to the name:tag specified as destination, also add these
signIdentity string // Identity of the signed image, must be a fully specified docker reference
digestFile string // Write digest to this file
quiet bool // Suppress output information when copying images
all bool // Copy all of the images if the source is a list
multiArch commonFlag.OptionalString // How to handle multi architecture images
encryptLayer []int // The list of layers to encrypt
encryptionKeys []string // Keys needed to encrypt the image
decryptionKeys []string // Keys needed to decrypt the image
imageParallelCopies uint // Maximum number of parallel requests when copying images
stripRemovedPlatforms bool // Strip platforms not selected for copy when using --multi-arch with a platform list
}
func copyCmd(global *globalOptions) *cobra.Command {
@@ -85,6 +86,7 @@ See skopeo(1) section "IMAGE NAMES" for the expected format
flags.IntSliceVar(&opts.encryptLayer, "encrypt-layer", []int{}, "*Experimental* the 0-indexed layer indices, with support for negative indexing (e.g. 0 is the first layer, -1 is the last layer)")
flags.StringSliceVar(&opts.decryptionKeys, "decryption-key", []string{}, "*Experimental* key needed to decrypt the image")
flags.UintVar(&opts.imageParallelCopies, "image-parallel-copies", 0, "Maximum number of image layers to be copied (pulled/pushed) simultaneously. Not setting this field will fall back to containers/image defaults.")
flags.BoolVar(&opts.stripRemovedPlatforms, "strip-removed-platforms", false, "Strip removed platforms when copying specific platforms (requires signature removal)")
return cmd
}
@@ -257,6 +259,11 @@ func (opts *copyOptions) run(args []string, stdout io.Writer) (retErr error) {
copyOpts.OciEncryptConfig = encConfig
copyOpts.MaxParallelDownloads = opts.imageParallelCopies
copyOpts.ForceCompressionFormat = opts.destImage.forceCompressionFormat
if opts.stripRemovedPlatforms {
copyOpts.SparseManifestListAction = copy.StripSparseManifestList
} else {
copyOpts.SparseManifestListAction = copy.KeepSparseManifestList
}
return retry.IfNecessary(ctx, func() error {
manifestBytes, err := copy.Image(ctx, policyContext, destRef, srcRef, copyOpts)

View File

@@ -332,6 +332,7 @@ func (opts *imageDestOptions) warnAboutIneffectiveOptions(destTransport types.Im
// sharedCopyOptions collects CLI flags that affect copying images, currently shared between the copy and sync commands.
type sharedCopyOptions struct {
removeSignatures bool // Do not copy signatures from the source image
removeListSignatures bool // Do not copy manifest list signatures (preserves per-instance signatures)
signByFingerprint string // Sign the image using a GPG key with the specified fingerprint
signBySequoiaFingerprint string // Sign the image using a Sequoia-PGP key with the specified fingerprint
signBySigstoreParamFile string // Sign the image using a sigstore signature per configuration in a param file
@@ -346,6 +347,7 @@ func sharedCopyFlags() (pflag.FlagSet, *sharedCopyOptions) {
opts := sharedCopyOptions{}
fs := pflag.FlagSet{}
fs.BoolVar(&opts.removeSignatures, "remove-signatures", false, "Do not copy signatures from source")
fs.BoolVar(&opts.removeListSignatures, "remove-list-signatures", false, "Do not copy manifest list signatures (preserves per-instance signatures)")
fs.StringVar(&opts.signByFingerprint, "sign-by", "", "Sign the image using a GPG key with the specified `FINGERPRINT`")
fs.StringVar(&opts.signBySequoiaFingerprint, "sign-by-sq-fingerprint", "", "Sign the image using a Sequoia-PGP key with the specified `FINGERPRINT`")
fs.StringVar(&opts.signBySigstoreParamFile, "sign-by-sigstore", "", "Sign the image using a sigstore parameter file at `PATH`")
@@ -368,6 +370,10 @@ func (opts *sharedCopyOptions) copyOptions(stdout io.Writer) (*copy.Options, fun
manifestType = mt
}
if opts.removeSignatures && opts.removeListSignatures {
return nil, nil, fmt.Errorf("Only one of --remove-signatures and --remove-list-signatures can be specified")
}
// c/image/copy.Image does allow creating both simple signing and sigstore signatures simultaneously,
// with independent passphrases, but that would make the CLI probably too confusing.
// For now, use the passphrase with either, but only one of them.
@@ -453,6 +459,7 @@ func (opts *sharedCopyOptions) copyOptions(stdout io.Writer) (*copy.Options, fun
succeeded = true
return &copy.Options{
RemoveSignatures: opts.removeSignatures,
RemoveListSignatures: opts.removeListSignatures,
Signers: signers,
SignBy: opts.signByFingerprint,
SignPassphrase: passphrase,

View File

@@ -404,6 +404,15 @@ func TestSharedCopyOptionsCopyOptions(t *testing.T) {
ForceManifestMIMEType: imgspecv1.MediaTypeImageManifest,
},
},
{ // --remove-list-signatures
options: []string{
"--remove-list-signatures",
},
expected: copy.Options{
RemoveListSignatures: true,
ReportWriter: &someStdout,
},
},
{ // --sign-passphrase-file + --sign-by work
options: []string{
"--sign-by", "gpgFingerprint",
@@ -469,7 +478,8 @@ func TestSharedCopyOptionsCopyOptions(t *testing.T) {
}
for _, opts := range [][]string{
{"--format", "invalid"}, // Invalid --format
{"--format", "invalid"}, // Invalid --format
{"--remove-signatures", "--remove-list-signatures"}, // Mutually exclusive flags
// More --sign-by-sigstore-private-key, --sign-by-sigstore failure cases should be tested here.
// --sign-passphrase-file + more than one key option
{"--sign-by", "gpgFingerprint", "--sign-by-sq-fingerprint", "sqFingerprint", "--sign-passphrase-file", passphraseFile},

View File

@@ -97,6 +97,16 @@ Suppress output information when copying images.
Do not copy signatures, if any, from _source-image_. Necessary when copying a signed image to a destination which does not support signatures.
**--remove-list-signatures**
Do not copy the manifest list signature while preserving per-instance signatures. This provides more granular control than **--remove-signatures**, which removes all signatures.
**--strip-removed-platforms**
When copying only a subset of platforms from a multi-architecture image (using **--multi-arch** with a platform list), write a manifest list containing only the copied platforms, instead of leaving entries that refer to per-platform images not present in the destination. This is useful for registries that reject manifest lists containing references to missing images.
Note: This option requires signatures to be removed using either **--remove-signatures** or **--remove-list-signatures**, as stripping instances invalidates the manifest list signature and changes the manifest list digest. The operation will fail if signatures are present and not explicitly removed.
**--sign-by** _key-id_
Add a “simple signing” signature using that key ID for an image name corresponding to _destination-image_
@@ -273,6 +283,11 @@ To copy only specific platforms from a multi-architecture image (creates a spars
$ skopeo copy --multi-arch=linux/amd64,linux/arm64 docker://quay.io/skopeo/stable:latest docker://registry.example.com/skopeo:latest
```
To copy specific platforms and strip the sparse manifest list (for registries that don't support sparse lists):
```console
$ skopeo copy --multi-arch=linux/amd64,linux/arm64 --strip-removed-platforms --remove-list-signatures docker://quay.io/skopeo/stable:latest docker://registry.example.com/skopeo:latest
```
To encrypt an image:
```console
$ skopeo copy docker://docker.io/library/nginx:1.17.8 oci:local_nginx:1.17.8

View File

@@ -90,6 +90,10 @@ This option does not change what will be copied; consider using `--all` at the s
**--remove-signatures** Do not copy signatures, if any, from _source-image_. This is necessary when copying a signed image to a destination which does not support signatures.
**--remove-list-signatures**
Do not copy the manifest list signature while preserving per-instance signatures. This provides more granular control than **--remove-signatures**, which removes all signatures.
**--sign-by** _key-id_
Add a “simple signing” signature using that key ID for an image name corresponding to _destination-image_

View File

@@ -204,6 +204,58 @@ func (s *copySuite) TestCopyWithPlatformList() {
assert.Equal(t, 3, len(manifestFiles), "Expected manifest list + 2 platform manifests")
}
func (s *copySuite) TestCopyPlatformListWithStripSparse() {
t := s.T()
dir1 := t.TempDir()
sourceManifest := combinedOutputOfCommand(t, skopeoBinary, "inspect", "--retry-times", "3", "--raw", knownListImage)
sourceList, err := manifest.Schema2ListFromManifest([]byte(sourceManifest))
require.NoError(t, err)
var strippedPlatform string
for _, m := range sourceList.Manifests {
p := m.Platform.OS + "/" + m.Platform.Architecture
if p != "linux/amd64" && p != "linux/arm64" {
strippedPlatform = p
break
}
}
require.NotEmpty(t, strippedPlatform, "source image should contain a platform outside linux/amd64,linux/arm64")
assertSkopeoSucceeds(t, "", "copy", "--retry-times", "3", "--multi-arch=linux/amd64,linux/arm64",
"--strip-removed-platforms", "--remove-list-signatures", knownListImage, "dir:"+dir1)
manifestPath := filepath.Join(dir1, "manifest.json")
readManifest, err := os.ReadFile(manifestPath)
require.NoError(t, err)
mimeType := manifest.GuessMIMEType(readManifest)
assert.Equal(t, "application/vnd.docker.distribution.manifest.list.v2+json", mimeType)
destList, err := manifest.Schema2ListFromManifest(readManifest)
require.NoError(t, err)
assert.Len(t, destList.Manifests, 2)
destPlatforms := map[string]struct{}{}
for _, m := range destList.Manifests {
destPlatforms[m.Platform.OS+"/"+m.Platform.Architecture] = struct{}{}
}
assert.Contains(t, destPlatforms, "linux/amd64")
assert.Contains(t, destPlatforms, "linux/arm64")
assert.NotContains(t, destPlatforms, strippedPlatform)
// Verify that we still have 2 platform manifests (manifest list + 2 platforms = 3 manifest files)
manifestFiles, err := filepath.Glob(filepath.Join(dir1, "*manifest.json"))
require.NoError(t, err)
assert.Equal(t, 3, len(manifestFiles), "Expected manifest list + 2 platform manifests after stripping")
}
func (s *copySuite) TestCopyPlatformListStripRemovedPlatformsFailsWhenManifestListCannotBeModified() {
t := s.T()
dir1 := t.TempDir()
assertSkopeoFails(t, `.*Instructed to preserve digests.*`,
"copy", "--retry-times", "3", "--multi-arch=linux/amd64,linux/arm64",
"--strip-removed-platforms", "--preserve-digests", knownListImage, "dir:"+dir1)
}
func (s *copySuite) TestCopyWithManifestListConverge() {
t := s.T()
oci1 := t.TempDir()