diff --git a/cmd/skopeo/copy.go b/cmd/skopeo/copy.go index 9d47a5c4..bed81d19 100644 --- a/cmd/skopeo/copy.go +++ b/cmd/skopeo/copy.go @@ -12,9 +12,6 @@ import ( "github.com/containers/image/v5/copy" "github.com/containers/image/v5/docker/reference" "github.com/containers/image/v5/manifest" - "github.com/containers/image/v5/pkg/cli" - "github.com/containers/image/v5/pkg/cli/sigstore" - "github.com/containers/image/v5/signature/signer" "github.com/containers/image/v5/transports" "github.com/containers/image/v5/transports/alltransports" encconfig "github.com/containers/ocicrypt/config" @@ -23,28 +20,22 @@ import ( ) type copyOptions struct { - global *globalOptions - deprecatedTLSVerify *deprecatedTLSVerifyOption - srcImage *imageOptions - destImage *imageDestOptions - retryOpts *retry.Options - additionalTags []string // For docker-archive: destinations, in addition to the name:tag specified as destination, also add these - removeSignatures bool // Do not copy signatures from the source image - signByFingerprint string // Sign the image using a GPG key with the specified fingerprint - signBySigstoreParamFile string // Sign the image using a sigstore signature per configuration in a param file - signBySigstorePrivateKey string // Sign the image using a sigstore private key - signPassphraseFile string // Path pointing to a passphrase file when signing (for either signature format, but only one of them) - signIdentity string // Identity of the signed image, must be a fully specified docker reference - digestFile string // Write digest to this file - format commonFlag.OptionalString // Force conversion of the image to a specified format - 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 - preserveDigests bool // Preserve digests during copy - 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 } func copyCmd(global *globalOptions) *cobra.Command { @@ -53,11 +44,13 @@ func copyCmd(global *globalOptions) *cobra.Command { srcFlags, srcOpts := imageFlags(global, sharedOpts, deprecatedTLSVerifyOpt, "src-", "screds") destFlags, destOpts := imageDestFlags(global, sharedOpts, deprecatedTLSVerifyOpt, "dest-", "dcreds") retryFlags, retryOpts := retryFlags() + copyFlags, copyOpts := sharedCopyFlags() opts := copyOptions{global: global, deprecatedTLSVerify: deprecatedTLSVerifyOpt, srcImage: srcOpts, destImage: destOpts, retryOpts: retryOpts, + copy: copyOpts, } cmd := &cobra.Command{ Use: "copy [command options] SOURCE-IMAGE DESTINATION-IMAGE", @@ -80,19 +73,13 @@ See skopeo(1) section "IMAGE NAMES" for the expected format flags.AddFlagSet(&srcFlags) flags.AddFlagSet(&destFlags) flags.AddFlagSet(&retryFlags) + flags.AddFlagSet(©Flags) flags.StringSliceVar(&opts.additionalTags, "additional-tag", []string{}, "additional tags (supports docker-archive)") flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Suppress output information when copying images") flags.BoolVarP(&opts.all, "all", "a", false, "Copy all images if SOURCE-IMAGE is a list") flags.Var(commonFlag.NewOptionalStringValue(&opts.multiArch), "multi-arch", `How to handle multi-architecture images (system, all, or index-only)`) - flags.BoolVar(&opts.preserveDigests, "preserve-digests", false, "Preserve digests of images and lists") - flags.BoolVar(&opts.removeSignatures, "remove-signatures", false, "Do not copy signatures from SOURCE-IMAGE") - flags.StringVar(&opts.signByFingerprint, "sign-by", "", "Sign the image using a GPG key with the specified `FINGERPRINT`") - flags.StringVar(&opts.signBySigstoreParamFile, "sign-by-sigstore", "", "Sign the image using a sigstore parameter file at `PATH`") - flags.StringVar(&opts.signBySigstorePrivateKey, "sign-by-sigstore-private-key", "", "Sign the image using a sigstore private key at `PATH`") - flags.StringVar(&opts.signPassphraseFile, "sign-passphrase-file", "", "Read a passphrase for signing an image from `PATH`") flags.StringVar(&opts.signIdentity, "sign-identity", "", "Identity of signed image, must be a fully specified docker reference. Defaults to the target docker reference.") flags.StringVar(&opts.digestFile, "digestfile", "", "Write the digest of the pushed image to the specified file") - flags.VarP(commonFlag.NewOptionalStringValue(&opts.format), "format", "f", `MANIFEST TYPE (oci, v2s1, or v2s2) to use in the destination (default is manifest type of source, with fallbacks)`) flags.StringSliceVar(&opts.encryptionKeys, "encryption-key", []string{}, "*Experimental* key with the encryption protocol to use needed to encrypt the image (e.g. jwe:/path/to/key.pem)") 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") @@ -160,14 +147,6 @@ func (opts *copyOptions) run(args []string, stdout io.Writer) (retErr error) { return err } - var manifestType string - if opts.format.Present() { - manifestType, err = parseManifestFormat(opts.format.Value()) - if err != nil { - return err - } - } - for _, image := range opts.additionalTags { ref, err := reference.ParseNormalizedNamed(image) if err != nil { @@ -237,43 +216,6 @@ func (opts *copyOptions) run(args []string, stdout io.Writer) (retErr error) { decConfig = cc.DecryptConfig } - // 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. - if opts.signPassphraseFile != "" && opts.signByFingerprint != "" && opts.signBySigstorePrivateKey != "" { - return fmt.Errorf("Only one of --sign-by and sign-by-sigstore-private-key can be used with sign-passphrase-file") - } - var passphrase string - if opts.signPassphraseFile != "" { - p, err := cli.ReadPassphraseFile(opts.signPassphraseFile) - if err != nil { - return err - } - passphrase = p - } else if opts.signBySigstorePrivateKey != "" { - p, err := promptForPassphrase(opts.signBySigstorePrivateKey, os.Stdin, os.Stdout) - if err != nil { - return err - } - passphrase = p - } // opts.signByFingerprint triggers a GPG-agent passphrase prompt, possibly using a more secure channel, so we usually shouldn’t prompt ourselves if no passphrase was explicitly provided. - - var signers []*signer.Signer - if opts.signBySigstoreParamFile != "" { - signer, err := sigstore.NewSignerFromParameterFile(opts.signBySigstoreParamFile, &sigstore.Options{ - PrivateKeyPassphrasePrompt: func(keyFile string) (string, error) { - return promptForPassphrase(keyFile, os.Stdin, os.Stdout) - }, - Stdin: os.Stdin, - Stdout: stdout, - }) - if err != nil { - return fmt.Errorf("Error using --sign-by-sigstore: %w", err) - } - defer signer.Close() - signers = append(signers, signer) - } - var signIdentity reference.Named = nil if opts.signIdentity != "" { signIdentity, err = reference.ParseNamed(opts.signIdentity) @@ -284,26 +226,22 @@ func (opts *copyOptions) run(args []string, stdout io.Writer) (retErr error) { opts.destImage.warnAboutIneffectiveOptions(destRef.Transport()) + copyOpts, cleanupOptions, err := opts.copy.copyOptions(stdout) + if err != nil { + return err + } + defer cleanupOptions() + copyOpts.SignIdentity = signIdentity + copyOpts.SourceCtx = sourceCtx + copyOpts.DestinationCtx = destinationCtx + copyOpts.ImageListSelection = imageListSelection + copyOpts.OciDecryptConfig = decConfig + copyOpts.OciEncryptLayers = encLayers + copyOpts.OciEncryptConfig = encConfig + copyOpts.MaxParallelDownloads = opts.imageParallelCopies + return retry.IfNecessary(ctx, func() error { - manifestBytes, err := copy.Image(ctx, policyContext, destRef, srcRef, ©.Options{ - RemoveSignatures: opts.removeSignatures, - Signers: signers, - SignBy: opts.signByFingerprint, - SignPassphrase: passphrase, - SignBySigstorePrivateKeyFile: opts.signBySigstorePrivateKey, - SignSigstorePrivateKeyPassphrase: []byte(passphrase), - SignIdentity: signIdentity, - ReportWriter: stdout, - SourceCtx: sourceCtx, - DestinationCtx: destinationCtx, - ForceManifestMIMEType: manifestType, - ImageListSelection: imageListSelection, - PreserveDigests: opts.preserveDigests, - OciDecryptConfig: decConfig, - OciEncryptLayers: encLayers, - OciEncryptConfig: encConfig, - MaxParallelDownloads: opts.imageParallelCopies, - }) + manifestBytes, err := copy.Image(ctx, policyContext, destRef, srcRef, copyOpts) if err != nil { return err } diff --git a/cmd/skopeo/inspect.go b/cmd/skopeo/inspect.go index 843f26e9..c620866d 100644 --- a/cmd/skopeo/inspect.go +++ b/cmd/skopeo/inspect.go @@ -57,13 +57,13 @@ skopeo inspect --format "Name: {{.Name}} Digest: {{.Digest}}" docker://registry. } adjustUsage(cmd) flags := cmd.Flags() + flags.AddFlagSet(&sharedFlags) + flags.AddFlagSet(&imageFlags) + flags.AddFlagSet(&retryFlags) flags.BoolVar(&opts.raw, "raw", false, "output raw manifest or configuration") flags.BoolVar(&opts.config, "config", false, "output configuration") flags.StringVarP(&opts.format, "format", "f", "", "Format the output to a Go template") flags.BoolVarP(&opts.doNotListTags, "no-tags", "n", false, "Do not list the available tags from the repository in the output") - flags.AddFlagSet(&sharedFlags) - flags.AddFlagSet(&imageFlags) - flags.AddFlagSet(&retryFlags) return cmd } diff --git a/cmd/skopeo/login.go b/cmd/skopeo/login.go index efaf3539..3922c60a 100644 --- a/cmd/skopeo/login.go +++ b/cmd/skopeo/login.go @@ -29,8 +29,8 @@ func loginCmd(global *globalOptions) *cobra.Command { } adjustUsage(cmd) flags := cmd.Flags() - commonFlag.OptionalBoolFlag(flags, &opts.tlsVerify, "tls-verify", "require HTTPS and verify certificates when accessing the registry") flags.AddFlagSet(auth.GetLoginFlags(&opts.loginOpts)) + commonFlag.OptionalBoolFlag(flags, &opts.tlsVerify, "tls-verify", "require HTTPS and verify certificates when accessing the registry") return cmd } diff --git a/cmd/skopeo/logout.go b/cmd/skopeo/logout.go index a694d57c..0601818d 100644 --- a/cmd/skopeo/logout.go +++ b/cmd/skopeo/logout.go @@ -28,8 +28,8 @@ func logoutCmd(global *globalOptions) *cobra.Command { } adjustUsage(cmd) flags := cmd.Flags() - commonFlag.OptionalBoolFlag(flags, &opts.tlsVerify, "tls-verify", "require HTTPS and verify certificates when accessing the registry") flags.AddFlagSet(auth.GetLogoutFlags(&opts.logoutOpts)) + commonFlag.OptionalBoolFlag(flags, &opts.tlsVerify, "tls-verify", "require HTTPS and verify certificates when accessing the registry") return cmd } diff --git a/cmd/skopeo/sync.go b/cmd/skopeo/sync.go index 85d0a4c5..161b178c 100644 --- a/cmd/skopeo/sync.go +++ b/cmd/skopeo/sync.go @@ -14,16 +14,12 @@ import ( "strings" "github.com/Masterminds/semver/v3" - commonFlag "github.com/containers/common/pkg/flag" "github.com/containers/common/pkg/retry" "github.com/containers/image/v5/copy" "github.com/containers/image/v5/directory" "github.com/containers/image/v5/docker" "github.com/containers/image/v5/docker/reference" "github.com/containers/image/v5/manifest" - "github.com/containers/image/v5/pkg/cli" - "github.com/containers/image/v5/pkg/cli/sigstore" - "github.com/containers/image/v5/signature/signer" "github.com/containers/image/v5/transports" "github.com/containers/image/v5/types" "github.com/opencontainers/go-digest" @@ -34,26 +30,20 @@ import ( // syncOptions contains information retrieved from the skopeo sync command line. type syncOptions struct { - global *globalOptions // Global (not command dependent) skopeo options - deprecatedTLSVerify *deprecatedTLSVerifyOption - srcImage *imageOptions // Source image options - destImage *imageDestOptions // Destination image options - retryOpts *retry.Options - removeSignatures bool // Do not copy signatures from the source image - signByFingerprint string // Sign the image using a GPG key with the specified fingerprint - signBySigstoreParamFile string // Sign the image using a sigstore signature per configuration in a param file - signBySigstorePrivateKey string // Sign the image using a sigstore private key - signPassphraseFile string // Path pointing to a passphrase file when signing - format commonFlag.OptionalString // Force conversion of the image to a specified format - source string // Source repository name - destination string // Destination registry name - digestFile string // Write digest to this file - scoped bool // When true, namespace copied images at destination using the source repository name - all bool // Copy all of the images if an image in the source is a list - dryRun bool // Don't actually copy anything, just output what it would have done - preserveDigests bool // Preserve digests during sync - keepGoing bool // Whether or not to abort the sync if there are any errors during syncing the images - appendSuffix string // Suffix to append to destination image tag + global *globalOptions // Global (not command dependent) skopeo options + deprecatedTLSVerify *deprecatedTLSVerifyOption + srcImage *imageOptions // Source image options + destImage *imageDestOptions // Destination image options + retryOpts *retry.Options + copy *sharedCopyOptions + source string // Source repository name + destination string // Destination registry name + digestFile string // Write digest to this file + scoped bool // When true, namespace copied images at destination using the source repository name + all bool // Copy all of the images if an image in the source is a list + dryRun bool // Don't actually copy anything, just output what it would have done + keepGoing bool // Whether or not to abort the sync if there are any errors during syncing the images + appendSuffix string // Suffix to append to destination image tag } // repoDescriptor contains information of a single repository used as a sync source. @@ -89,6 +79,7 @@ func syncCmd(global *globalOptions) *cobra.Command { srcFlags, srcOpts := dockerImageFlags(global, sharedOpts, deprecatedTLSVerifyOpt, "src-", "screds") destFlags, destOpts := dockerImageFlags(global, sharedOpts, deprecatedTLSVerifyOpt, "dest-", "dcreds") retryFlags, retryOpts := retryFlags() + copyFlags, copyOpts := sharedCopyFlags() opts := syncOptions{ global: global, @@ -96,6 +87,7 @@ func syncCmd(global *globalOptions) *cobra.Command { srcImage: srcOpts, destImage: &imageDestOptions{imageOptions: destOpts}, retryOpts: retryOpts, + copy: copyOpts, } cmd := &cobra.Command{ @@ -113,12 +105,12 @@ See skopeo-sync(1) for details. } adjustUsage(cmd) flags := cmd.Flags() - flags.BoolVar(&opts.removeSignatures, "remove-signatures", false, "Do not copy signatures from SOURCE images") - flags.StringVar(&opts.signByFingerprint, "sign-by", "", "Sign the image using a GPG key with the specified `FINGERPRINT`") - flags.StringVar(&opts.signBySigstoreParamFile, "sign-by-sigstore", "", "Sign the image using a sigstore parameter file at `PATH`") - flags.StringVar(&opts.signBySigstorePrivateKey, "sign-by-sigstore-private-key", "", "Sign the image using a sigstore private key at `PATH`") - flags.StringVar(&opts.signPassphraseFile, "sign-passphrase-file", "", "File that contains a passphrase for the --sign-by key") - flags.VarP(commonFlag.NewOptionalStringValue(&opts.format), "format", "f", `MANIFEST TYPE (oci, v2s1, or v2s2) to use when syncing image(s) to a destination (default is manifest type of source, with fallbacks)`) + flags.AddFlagSet(&sharedFlags) + flags.AddFlagSet(&deprecatedTLSVerifyFlags) + flags.AddFlagSet(&srcFlags) + flags.AddFlagSet(&destFlags) + flags.AddFlagSet(&retryFlags) + flags.AddFlagSet(©Flags) flags.StringVarP(&opts.source, "src", "s", "", "SOURCE transport type") flags.StringVarP(&opts.destination, "dest", "d", "", "DESTINATION transport type") flags.BoolVar(&opts.scoped, "scoped", false, "Images at DESTINATION are prefix using the full source image path as scope") @@ -126,13 +118,7 @@ See skopeo-sync(1) for details. flags.StringVar(&opts.digestFile, "digestfile", "", "Write the digests and Image References of the resulting images to the specified file, separated by newlines") flags.BoolVarP(&opts.all, "all", "a", false, "Copy all images if SOURCE-IMAGE is a list") flags.BoolVar(&opts.dryRun, "dry-run", false, "Run without actually copying data") - flags.BoolVar(&opts.preserveDigests, "preserve-digests", false, "Preserve digests of images and lists") flags.BoolVarP(&opts.keepGoing, "keep-going", "", false, "Do not abort the sync if any image copy fails") - flags.AddFlagSet(&sharedFlags) - flags.AddFlagSet(&deprecatedTLSVerifyFlags) - flags.AddFlagSet(&srcFlags) - flags.AddFlagSet(&destFlags) - flags.AddFlagSet(&retryFlags) return cmd } @@ -643,14 +629,6 @@ func (opts *syncOptions) run(args []string, stdout io.Writer) (retErr error) { return err } - var manifestType string - if opts.format.Present() { - manifestType, err = parseManifestFormat(opts.format.Value()) - if err != nil { - return err - } - } - ctx, cancel := opts.global.commandTimeoutContext() defer cancel() @@ -669,57 +647,15 @@ func (opts *syncOptions) run(args []string, stdout io.Writer) (retErr error) { return err } - // 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. - if opts.signPassphraseFile != "" && opts.signByFingerprint != "" && opts.signBySigstorePrivateKey != "" { - return fmt.Errorf("Only one of --sign-by and sign-by-sigstore-private-key can be used with sign-passphrase-file") - } - var passphrase string - if opts.signPassphraseFile != "" { - p, err := cli.ReadPassphraseFile(opts.signPassphraseFile) - if err != nil { - return err - } - passphrase = p - } else if opts.signBySigstorePrivateKey != "" { - p, err := promptForPassphrase(opts.signBySigstorePrivateKey, os.Stdin, os.Stdout) - if err != nil { - return err - } - passphrase = p + options, cleanupOptions, err := opts.copy.copyOptions(stdout) + if err != nil { + return err } + defer cleanupOptions() + options.DestinationCtx = destinationCtx + options.ImageListSelection = imageListSelection + options.OptimizeDestinationImageAlreadyExists = true - var signers []*signer.Signer - if opts.signBySigstoreParamFile != "" { - signer, err := sigstore.NewSignerFromParameterFile(opts.signBySigstoreParamFile, &sigstore.Options{ - PrivateKeyPassphrasePrompt: func(keyFile string) (string, error) { - return promptForPassphrase(keyFile, os.Stdin, os.Stdout) - }, - Stdin: os.Stdin, - Stdout: stdout, - }) - if err != nil { - return fmt.Errorf("Error using --sign-by-sigstore: %w", err) - } - defer signer.Close() - signers = append(signers, signer) - } - - options := copy.Options{ - RemoveSignatures: opts.removeSignatures, - Signers: signers, - SignBy: opts.signByFingerprint, - SignPassphrase: passphrase, - SignBySigstorePrivateKeyFile: opts.signBySigstorePrivateKey, - SignSigstorePrivateKeyPassphrase: []byte(passphrase), - ReportWriter: stdout, - DestinationCtx: destinationCtx, - ImageListSelection: imageListSelection, - PreserveDigests: opts.preserveDigests, - OptimizeDestinationImageAlreadyExists: true, - ForceManifestMIMEType: manifestType, - } errorsPresent := false imagesNumber := 0 if opts.dryRun { @@ -775,7 +711,7 @@ func (opts *syncOptions) run(args []string, stdout io.Writer) (retErr error) { } else { logrus.WithFields(fromToFields).Infof("Copying image ref %d/%d", counter+1, len(srcRepo.ImageRefs)) if err = retry.IfNecessary(ctx, func() error { - manifestBytes, err = copy.Image(ctx, policyContext, destRef, ref, &options) + manifestBytes, err = copy.Image(ctx, policyContext, destRef, ref, options) return err }, opts.retryOpts); err != nil { if !opts.keepGoing { diff --git a/cmd/skopeo/utils.go b/cmd/skopeo/utils.go index 77ce7704..3f40f5ee 100644 --- a/cmd/skopeo/utils.go +++ b/cmd/skopeo/utils.go @@ -11,11 +11,15 @@ import ( commonFlag "github.com/containers/common/pkg/flag" "github.com/containers/common/pkg/retry" + "github.com/containers/image/v5/copy" "github.com/containers/image/v5/directory" "github.com/containers/image/v5/manifest" ociarchive "github.com/containers/image/v5/oci/archive" ocilayout "github.com/containers/image/v5/oci/layout" + "github.com/containers/image/v5/pkg/cli" + "github.com/containers/image/v5/pkg/cli/sigstore" "github.com/containers/image/v5/pkg/compression" + "github.com/containers/image/v5/signature/signer" "github.com/containers/image/v5/storage" "github.com/containers/image/v5/transports/alltransports" "github.com/containers/image/v5/types" @@ -176,9 +180,9 @@ func imageFlags(global *globalOptions, shared *sharedImageOptions, deprecatedTLS dockerFlags, opts := dockerImageFlags(global, shared, deprecatedTLSVerify, flagPrefix, credsOptionAlias) fs := pflag.FlagSet{} + fs.AddFlagSet(&dockerFlags) fs.StringVar(&opts.sharedBlobDir, flagPrefix+"shared-blob-dir", "", "`DIRECTORY` to use to share blobs across OCI repositories") fs.StringVar(&opts.dockerDaemonHost, flagPrefix+"daemon-host", "", "use docker daemon host at `HOST` (docker-daemon: only)") - fs.AddFlagSet(&dockerFlags) return fs, opts } @@ -319,6 +323,110 @@ 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 + signByFingerprint string // Sign the image using a GPG key with the specified fingerprint + signBySigstoreParamFile string // Sign the image using a sigstore signature per configuration in a param file + signBySigstorePrivateKey string // Sign the image using a sigstore private key + signPassphraseFile string // Path pointing to a passphrase file when signing + preserveDigests bool // Preserve digests during copy + format commonFlag.OptionalString // Force conversion of the image to a specified format +} + +// sharedCopyFlags prepares a collection of CLI flags writing into sharedCopyoptions. +func sharedCopyFlags() (pflag.FlagSet, *sharedCopyOptions) { + opts := sharedCopyOptions{} + fs := pflag.FlagSet{} + fs.BoolVar(&opts.removeSignatures, "remove-signatures", false, "Do not copy signatures from source") + fs.StringVar(&opts.signByFingerprint, "sign-by", "", "Sign the image using a GPG key with the specified `FINGERPRINT`") + fs.StringVar(&opts.signBySigstoreParamFile, "sign-by-sigstore", "", "Sign the image using a sigstore parameter file at `PATH`") + fs.StringVar(&opts.signBySigstorePrivateKey, "sign-by-sigstore-private-key", "", "Sign the image using a sigstore private key at `PATH`") + fs.StringVar(&opts.signPassphraseFile, "sign-passphrase-file", "", "Read a passphrase for signing an image from `PATH`") + fs.VarP(commonFlag.NewOptionalStringValue(&opts.format), "format", "f", `MANIFEST TYPE (oci, v2s1, or v2s2) to use in the destination (default is manifest type of source, with fallbacks)`) + fs.BoolVar(&opts.preserveDigests, "preserve-digests", false, "Preserve digests of images and lists") + return fs, &opts +} + +// copyOptions interprets opts, returns a partially-filled *copy.Options, +// and a function that should be called to clean up. +func (opts *sharedCopyOptions) copyOptions(stdout io.Writer) (*copy.Options, func(), error) { + var manifestType string + if opts.format.Present() { + mt, err := parseManifestFormat(opts.format.Value()) + if err != nil { + return nil, nil, err + } + manifestType = mt + } + + // 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. + if opts.signPassphraseFile != "" && opts.signByFingerprint != "" && opts.signBySigstorePrivateKey != "" { + return nil, nil, fmt.Errorf("Only one of --sign-by and sign-by-sigstore-private-key can be used with sign-passphrase-file") + } + var passphrase string + if opts.signPassphraseFile != "" { + p, err := cli.ReadPassphraseFile(opts.signPassphraseFile) + if err != nil { + return nil, nil, err + } + passphrase = p + } else if opts.signBySigstorePrivateKey != "" { + p, err := promptForPassphrase(opts.signBySigstorePrivateKey, os.Stdin, os.Stdout) + if err != nil { + return nil, nil, err + } + passphrase = p + } // opts.signByFingerprint triggers a GPG-agent passphrase prompt, possibly using a more secure channel, so we usually shouldn’t prompt ourselves if no passphrase was explicitly provided. + var passphraseBytes []byte + if passphrase != "" { + passphraseBytes = []byte(passphrase) + } + + var signers []*signer.Signer + closeSigners := func() { + for _, signer := range signers { + signer.Close() + } + } + succeeded := false + defer func() { + if !succeeded { + closeSigners() + } + }() + if opts.signBySigstoreParamFile != "" { + signer, err := sigstore.NewSignerFromParameterFile(opts.signBySigstoreParamFile, &sigstore.Options{ + PrivateKeyPassphrasePrompt: func(keyFile string) (string, error) { + return promptForPassphrase(keyFile, os.Stdin, os.Stdout) + }, + Stdin: os.Stdin, + Stdout: stdout, + }) + if err != nil { + return nil, nil, fmt.Errorf("Error using --sign-by-sigstore: %w", err) + } + signers = append(signers, signer) + } + + succeeded = true + return ©.Options{ + RemoveSignatures: opts.removeSignatures, + Signers: signers, + SignBy: opts.signByFingerprint, + SignPassphrase: passphrase, + SignBySigstorePrivateKeyFile: opts.signBySigstorePrivateKey, + SignSigstorePrivateKeyPassphrase: passphraseBytes, + + ReportWriter: stdout, + + PreserveDigests: opts.preserveDigests, + ForceManifestMIMEType: manifestType, + }, closeSigners, nil +} + func parseCreds(creds string) (string, string, error) { if creds == "" { return "", "", errors.New("credentials can't be empty") diff --git a/cmd/skopeo/utils_test.go b/cmd/skopeo/utils_test.go index 150e4ec2..bcfa5497 100644 --- a/cmd/skopeo/utils_test.go +++ b/cmd/skopeo/utils_test.go @@ -1,9 +1,12 @@ package main import ( + "bytes" "errors" + "os" "testing" + "github.com/containers/image/v5/copy" "github.com/containers/image/v5/manifest" "github.com/containers/image/v5/types" imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1" @@ -350,6 +353,92 @@ func TestTLSVerifyFlags(t *testing.T) { } } +// fakeSharedCopyOptions creates sharedCopyOptions and sets it according to cmdFlags. +func fakeSharedCopyOptions(t *testing.T, cmdFlags []string) *sharedCopyOptions { + _, cmd := fakeGlobalOptions(t, []string{}) + sharedCopyFlags, sharedCopyOpts := sharedCopyFlags() + cmd.Flags().AddFlagSet(&sharedCopyFlags) + err := cmd.ParseFlags(cmdFlags) + require.NoError(t, err) + return sharedCopyOpts +} + +func TestSharedCopyOptionsCopyOptions(t *testing.T) { + someStdout := bytes.Buffer{} + + // Default state + opts := fakeSharedCopyOptions(t, []string{}) + res, cleanup, err := opts.copyOptions(&someStdout) + require.NoError(t, err) + defer cleanup() + assert.Equal(t, ©.Options{ + ReportWriter: &someStdout, + }, res) + + // Set most flags to non-default values + // This should also test --sign-by-sigstore and --sign-by-sigstore-private-key; we would have + // to create test keys for that. + opts = fakeSharedCopyOptions(t, []string{ + "--remove-signatures", + "--sign-by", "gpgFingerprint", + "--format", "oci", + "--preserve-digests", + }) + res, cleanup, err = opts.copyOptions(&someStdout) + require.NoError(t, err) + defer cleanup() + assert.Equal(t, ©.Options{ + RemoveSignatures: true, + SignBy: "gpgFingerprint", + ReportWriter: &someStdout, + PreserveDigests: true, + ForceManifestMIMEType: imgspecv1.MediaTypeImageManifest, + }, res) + + // --sign-passphrase-file + --sign-by work + passphraseFile, err := os.CreateTemp("", "passphrase") // Eventually we could refer to a passphrase fixture instead + require.NoError(t, err) + defer os.Remove(passphraseFile.Name()) + _, err = passphraseFile.WriteString("test-passphrase") + require.NoError(t, err) + opts = fakeSharedCopyOptions(t, []string{ + "--sign-by", "gpgFingerprint", + "--sign-passphrase-file", passphraseFile.Name(), + }) + res, cleanup, err = opts.copyOptions(&someStdout) + require.NoError(t, err) + defer cleanup() + assert.Equal(t, ©.Options{ + SignBy: "gpgFingerprint", + SignPassphrase: "test-passphrase", + SignSigstorePrivateKeyPassphrase: []byte("test-passphrase"), + ReportWriter: &someStdout, + }, res) + // --sign-passphrase-file + --sign-by-sigstore-private-key should be tested here. + + // Invalid --format + opts = fakeSharedCopyOptions(t, []string{"--format", "invalid"}) + _, _, err = opts.copyOptions(&someStdout) + assert.Error(t, err) + + // More --sign-passphrase-file, --sign-by-sigstore-private-key, --sign-by-sigstore failure cases should be tested here. + + // --sign-passphrase-file not found + opts = fakeSharedCopyOptions(t, []string{ + "--sign-by", "gpgFingerprint", + "--sign-passphrase-file", "/dev/null/this/does/not/exist", + }) + _, _, err = opts.copyOptions(&someStdout) + assert.Error(t, err) + + // --sign-by-sigstore file not found + opts = fakeSharedCopyOptions(t, []string{ + "--sign-by-sigstore", "/dev/null/this/does/not/exist", + }) + _, _, err = opts.copyOptions(&someStdout) + assert.Error(t, err) +} + func TestParseManifestFormat(t *testing.T) { for _, testCase := range []struct { formatParam string