Skip to content

Commit

Permalink
Add common UI library for confirmation prompts. (#2572)
Browse files Browse the repository at this point in the history
This library:

- Is testable.
- Is consistent across the CLI.

Related to (does not fix) #2296 and #2204.

Signed-off-by: Zachary Newman <zjn@chainguard.dev>

Signed-off-by: Zachary Newman <zjn@chainguard.dev>
  • Loading branch information
znewman01 committed Jan 2, 2023
1 parent 1adb83c commit f7993d0
Show file tree
Hide file tree
Showing 12 changed files with 373 additions and 83 deletions.
13 changes: 6 additions & 7 deletions cmd/cosign/cli/clean.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import (
"github.com/spf13/cobra"

"github.com/sigstore/cosign/v2/cmd/cosign/cli/options"
"github.com/sigstore/cosign/v2/pkg/cosign"
"github.com/sigstore/cosign/v2/internal/ui"
ociremote "github.com/sigstore/cosign/v2/pkg/oci/remote"
)

Expand All @@ -51,12 +51,11 @@ func Clean() *cobra.Command {
}

func CleanCmd(ctx context.Context, regOpts options.RegistryOptions, cleanType options.CleanType, imageRef string, force bool) error {
ok, err := cosign.ConfirmPrompt(prompt(cleanType), force)
if err != nil {
return err
}
if !ok {
return nil
if !force {
ui.Warn(ctx, prompt(cleanType))
if err := ui.ConfirmContinue(ctx); err != nil {
return err
}
}
ref, err := name.ParseReference(imageRef, regOpts.NameOptions()...)
if err != nil {
Expand Down
16 changes: 9 additions & 7 deletions cmd/cosign/cli/fulcio/fulcio.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import (
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"errors"
"fmt"
"net/url"
"os"
Expand All @@ -32,6 +31,7 @@ import (

"github.com/sigstore/cosign/v2/cmd/cosign/cli/options"
"github.com/sigstore/cosign/v2/internal/pkg/cosign/fulcio/fulcioroots"
"github.com/sigstore/cosign/v2/internal/ui"
"github.com/sigstore/cosign/v2/pkg/cosign"
"github.com/sigstore/cosign/v2/pkg/providers"
"github.com/sigstore/fulcio/pkg/api"
Expand Down Expand Up @@ -167,12 +167,14 @@ func NewSigner(ctx context.Context, ko options.KeyOpts) (*Signer, error) {
fmt.Fprintln(os.Stderr, "Non-interactive mode detected, using device flow.")
flow = flowDevice
default:
ok, err := cosign.ConfirmPrompt(privacyStatementConfirmation, ko.SkipConfirmation)
if err != nil {
return nil, err
}
if !ok {
return nil, errors.New("no confirmation")
if ko.SkipConfirmation {
// User requested to skip confirmation! We can continue.
} else {
ui.Info(ctx, privacyStatementConfirmation)
err := ui.ConfirmContinue(ctx)
if err != nil {
return nil, fmt.Errorf("privacy statement not confirmed: %w", err)
}
}
flow = flowNormal
}
Expand Down
14 changes: 4 additions & 10 deletions cmd/cosign/cli/generate/generate_key_pair.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/sigstore/cosign/v2/pkg/cosign/git/gitlab"

icos "github.com/sigstore/cosign/v2/internal/pkg/cosign"
"github.com/sigstore/cosign/v2/internal/ui"
"github.com/sigstore/cosign/v2/pkg/cosign"
"github.com/sigstore/cosign/v2/pkg/cosign/kubernetes"
"github.com/sigstore/sigstore/pkg/cryptoutils"
Expand Down Expand Up @@ -93,16 +94,9 @@ func GenerateKeyPairCmd(ctx context.Context, kmsVal string, args []string) error
}

if fileExists {
var overwrite string
fmt.Fprint(os.Stderr, "File cosign.key already exists. Overwrite (y/n)? ")
fmt.Scanf("%s", &overwrite)
switch overwrite {
case "y", "Y":
case "n", "N":
return nil
default:
fmt.Fprintln(os.Stderr, "Invalid input")
return nil
ui.Warn(ctx, "File import-cosign.key already exists. Overwrite?")
if err := ui.ConfirmContinue(ctx); err != nil {
return err
}
}
// TODO: make sure the perms are locked down first.
Expand Down
14 changes: 4 additions & 10 deletions cmd/cosign/cli/importkeypair/import_key_pair.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"os"

icos "github.com/sigstore/cosign/v2/internal/pkg/cosign"
"github.com/sigstore/cosign/v2/internal/ui"
"github.com/sigstore/cosign/v2/pkg/cosign"
"github.com/sigstore/cosign/v2/pkg/cosign/env"
)
Expand All @@ -44,16 +45,9 @@ func ImportKeyPairCmd(ctx context.Context, keyVal string, args []string) error {
}

if fileExists {
var overwrite string
fmt.Fprint(os.Stderr, "File import-cosign.key already exists. Overwrite (y/n)? ")
fmt.Scanf("%s", &overwrite)
switch overwrite {
case "y", "Y":
case "n", "N":
return nil
default:
fmt.Fprintln(os.Stderr, "Invalid input")
return nil
ui.Warn(ctx, "File import-cosign.key already exists. Overwrite?")
if err := ui.ConfirmContinue(ctx); err != nil {
return err
}
}
// TODO: make sure the perms are locked down first.
Expand Down
27 changes: 11 additions & 16 deletions cmd/cosign/cli/sign/sign.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import (
"encoding/pem"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
Expand All @@ -40,6 +39,7 @@ import (
ipayload "github.com/sigstore/cosign/v2/internal/pkg/cosign/payload"
irekor "github.com/sigstore/cosign/v2/internal/pkg/cosign/rekor"
"github.com/sigstore/cosign/v2/internal/pkg/cosign/tsa"
"github.com/sigstore/cosign/v2/internal/ui"
"github.com/sigstore/cosign/v2/pkg/cosign"
"github.com/sigstore/cosign/v2/pkg/cosign/pivkey"
"github.com/sigstore/cosign/v2/pkg/cosign/pkcs11key"
Expand All @@ -59,7 +59,7 @@ import (
_ "github.com/sigstore/cosign/v2/pkg/providers/all"
)

const TagReferenceMessage string = `WARNING: Image reference %s uses a tag, not a digest, to identify the image to sign.
const TagReferenceMessage string = `Image reference %s uses a tag, not a digest, to identify the image to sign.
This can lead you to sign a different image than the intended one. Please use a
digest (example.com/ubuntu@sha256:abc123...) rather than tag
Expand All @@ -84,15 +84,12 @@ func ShouldUploadToTlog(ctx context.Context, ko options.KeyOpts, ref name.Refere

// Check if the image is public (no auth in Get)
if _, err := remote.Get(ref, remote.WithContext(ctx)); err != nil {
fmt.Fprintf(os.Stderr, "%q appears to be a private repository, please confirm uploading to the transparency log at %q [Y/N]: ", ref.Context().String(), ko.RekorURL)

var tlogConfirmResponse string
if _, err := fmt.Scanln(&tlogConfirmResponse); err != nil {
fmt.Fprintf(os.Stderr, "\nWARNING: skipping transparency log upload (use --yes to skip confirmation: %v\n", err)
return false
}
if strings.ToUpper(tlogConfirmResponse) != "Y" {
fmt.Fprintln(os.Stderr, "not uploading to transparency log")
ui.Warn(ctx, "%q appears to be a private repository, please confirm uploading to the transparency log at %q", ref.Context().String(), ko.RekorURL)
var errPromptDeclined *ui.ErrPromptDeclined
if err := ui.ConfirmContinue(ctx); errors.As(err, &errPromptDeclined) {
ui.Info(ctx, "not uploading to transparency log")
} else if err != nil {
ui.Warn(ctx, "skipping transparency log upload (use --yes to skip confirmation): %v\n", err)
return false
}
}
Expand All @@ -110,16 +107,14 @@ func GetAttachedImageRef(ref name.Reference, attachment string, opts ...ociremot
}

// ParseOCIReference parses a string reference to an OCI image into a reference, warning if the reference did not include a digest.
func ParseOCIReference(refStr string, out io.Writer, opts ...name.Option) (name.Reference, error) {
func ParseOCIReference(ctx context.Context, refStr string, opts ...name.Option) (name.Reference, error) {
ref, err := name.ParseReference(refStr, opts...)
if err != nil {
return nil, fmt.Errorf("parsing reference: %w", err)
}
if _, ok := ref.(name.Digest); !ok {
msg := fmt.Sprintf(TagReferenceMessage, refStr)
if _, err := io.WriteString(out, msg); err != nil {
panic("cannot write")
}
ui.Warn(ctx, msg)
}
return ref, nil
}
Expand Down Expand Up @@ -165,7 +160,7 @@ func SignCmd(ro *options.RootOptions, ko options.KeyOpts, signOpts options.SignO
}
annotations := am.Annotations
for _, inputImg := range imgs {
ref, err := ParseOCIReference(inputImg, os.Stderr, regOpts.NameOptions()...)
ref, err := ParseOCIReference(ctx, inputImg, regOpts.NameOptions()...)
if err != nil {
return err
}
Expand Down
24 changes: 10 additions & 14 deletions cmd/cosign/cli/sign/sign_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ import (
"strings"
"testing"

"github.com/google/go-containerregistry/pkg/name"
"github.com/stretchr/testify/assert"

"github.com/sigstore/cosign/v2/cmd/cosign/cli/generate"
"github.com/sigstore/cosign/v2/cmd/cosign/cli/options"
"github.com/sigstore/cosign/v2/internal/ui"
"github.com/sigstore/cosign/v2/pkg/cosign"
"github.com/sigstore/cosign/v2/test"
"github.com/sigstore/sigstore/pkg/cryptoutils"
Expand Down Expand Up @@ -210,26 +211,21 @@ func Test_signerFromKeyRefFailureEmptyChainFile(t *testing.T) {

func Test_ParseOCIReference(t *testing.T) {
var tests = []struct {
ref string
expected string
ref string
expectedWarning string
}{
{"image:bytag", "WARNING: Image reference image:bytag uses a tag, not a digest"},
{"image:bytag@sha256:abcdef", ""},
{"image:@sha256:abcdef", ""},
}
for _, tt := range tests {
var buf strings.Builder
var opts []name.Option
ParseOCIReference(tt.ref, &buf, opts...)
actual := buf.String()
if len(tt.expected) == 0 {
if len(actual) != 0 {
t.Errorf("expected no warning, got %s", actual)
}
stderr := ui.RunWithTestCtx(func(ctx context.Context, write ui.WriteFunc) {
ParseOCIReference(ctx, tt.ref)
})
if len(tt.expectedWarning) > 0 {
assert.Contains(t, stderr, tt.expectedWarning, stderr, "bad warning message")
} else {
if strings.Contains(tt.expected, actual) {
t.Errorf("bad warning: expected match for `%s`, got %s", tt.expected, actual)
}
assert.Empty(t, stderr, "expected no warning")
}
}
}
89 changes: 89 additions & 0 deletions internal/ui/env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright 2023 The Sigstore Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ui

import (
"bytes"
"context"
"io"
"os"
)

// An Env is the environment that the CLI exists in.
//
// It contains handles to STDERR and STDIN. Eventually, it will contain
// configuration pertaining to the current invocation (e.g., is this a terminal
// or not).
//
// UI methods should be defined on an Env. Then, the Env can be
// changed for easy testing. The Env will be retrieved from the current
// application context.
type Env struct {
Stderr io.Writer
Stdin io.Reader
}

// defaultEnv returns the default environment (writing to os.Stderr and
// reading from os.Stdin).
func defaultEnv() *Env {
return &Env{
Stderr: os.Stderr,
Stdin: os.Stdin,
}
}

type ctxKey struct{}

func (c ctxKey) String() string {
return "cosign/ui:env"
}

var ctxKeyEnv = ctxKey{}

// getEnv gets the environment from ctx.
//
// If ctx does not contain an environment, getEnv returns the default
// environment (see defaultEnvironment).
func getEnv(ctx context.Context) *Env {
e, ok := ctx.Value(ctxKeyEnv).(*Env)
if !ok {
return defaultEnv()
}
return e
}

// WithEnv adds the environment to the context.
func WithEnv(ctx context.Context, e *Env) context.Context {
return context.WithValue(ctx, ctxKeyEnv, e)
}

type WriteFunc func(string)
type callbackFunc func(context.Context, WriteFunc)

// RunWithTestCtx runs the provided callback in a context with the UI
// environment swapped out for one that allows for easy testing and captures
// STDOUT.
//
// The callback has access to a function that writes to the test STDIN.
func RunWithTestCtx(callback callbackFunc) string {
var stdin bytes.Buffer
var stderr bytes.Buffer
e := Env{&stderr, &stdin}

ctx := WithEnv(context.Background(), &e)
write := func(msg string) { stdin.WriteString(msg) }
callback(ctx, write)

return stderr.String()
}
41 changes: 41 additions & 0 deletions internal/ui/log.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright 2023 The Sigstore Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ui

import (
"context"
"fmt"
)

func (w *Env) info(msg string, a ...any) {
msg = fmt.Sprintf(msg, a...)
fmt.Fprintln(w.Stderr, msg)
}

// Info logs an informational message. It works like fmt.Printf, except that it
// always has a trailing newline.
func Info(ctx context.Context, msg string, a ...any) {
getEnv(ctx).info(msg, a...)
}

func (w *Env) warn(msg string, a ...any) {
msg = fmt.Sprintf(msg, a...)
fmt.Fprintf(w.Stderr, "WARNING: %s\n", msg)
}

// Warn logs a warning message (prefixed by "WARNING:"). It works like
// fmt.Printf, except that it always has a trailing newline.
func Warn(ctx context.Context, msg string, a ...any) {
getEnv(ctx).warn(msg, a...)
}

0 comments on commit f7993d0

Please sign in to comment.