diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 14757f8dd5..a3eb4136ff 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -46,6 +46,7 @@ import ( "github.com/docker/compose/v5/cmd/display" "github.com/docker/compose/v5/cmd/formatter" + "github.com/docker/compose/v5/cmd/prompt" "github.com/docker/compose/v5/internal/tracing" "github.com/docker/compose/v5/pkg/api" "github.com/docker/compose/v5/pkg/compose" @@ -118,7 +119,10 @@ func AdaptCmd(fn CobraCommand) func(cmd *cobra.Command, args []string) error { }() err := fn(ctx, cmd, args) - if api.IsErrCanceled(err) || errors.Is(ctx.Err(), context.Canceled) { + // Ctrl+C at an interactive prompt never raises SIGINT (the prompt + // holds the terminal in raw mode): it surfaces as prompt.ErrInterrupt + // and deserves the same 130 status a real SIGINT gets. + if api.IsErrCanceled(err) || errors.Is(ctx.Err(), context.Canceled) || errors.Is(err, prompt.ErrInterrupt) { err = dockercli.StatusError{ StatusCode: 130, } diff --git a/cmd/compose/compose_test.go b/cmd/compose/compose_test.go index 708929ff8c..b459864558 100644 --- a/cmd/compose/compose_test.go +++ b/cmd/compose/compose_test.go @@ -17,10 +17,17 @@ package compose import ( + "context" + "errors" + "fmt" "testing" "github.com/compose-spec/compose-go/v2/types" + dockercli "github.com/docker/cli/cli" + "github.com/spf13/cobra" "gotest.tools/v3/assert" + + "github.com/docker/compose/v5/cmd/prompt" ) func TestFilterServices(t *testing.T) { @@ -53,3 +60,18 @@ func TestFilterServices(t *testing.T) { _, err = p.GetService("zot") assert.NilError(t, err) } + +// Ctrl+C at an interactive prompt surfaces as prompt.ErrInterrupt (raw mode +// swallows the SIGINT): the command must exit with the same 130 status a real +// SIGINT produces, not a generic failure. +func TestAdaptCmdMapsPromptInterruptTo130(t *testing.T) { + run := AdaptCmd(func(ctx context.Context, cmd *cobra.Command, args []string) error { + return fmt.Errorf("prompting: %w", prompt.ErrInterrupt) + }) + cmd := &cobra.Command{} + cmd.SetContext(t.Context()) + err := run(cmd, nil) + var status dockercli.StatusError + assert.Assert(t, errors.As(err, &status)) + assert.Equal(t, status.StatusCode, 130) +} diff --git a/cmd/compose/options_test.go b/cmd/compose/options_test.go index c015a7a723..13a33d7994 100644 --- a/cmd/compose/options_test.go +++ b/cmd/compose/options_test.go @@ -403,7 +403,7 @@ func TestConfirmRemoteIncludes(t *testing.T) { " - oci://registry.example.com/stack:latest\n" + " - git://github.com/user/repo.git\n" + "\nRemote includes could potentially be malicious. Make sure you trust the source.\n" + - "Do you want to continue?", + "Do you want to continue? [y/N]: ", }, { name: "user rejects remote includes", @@ -422,7 +422,7 @@ func TestConfirmRemoteIncludes(t *testing.T) { wantOutput: "\nWarning: This Compose project includes files from remote sources:\n" + " - oci://registry.example.com/stack:latest\n" + "\nRemote includes could potentially be malicious. Make sure you trust the source.\n" + - "Do you want to continue?", + "Do you want to continue? [y/N]: ", }, } diff --git a/cmd/prompt/prompt.go b/cmd/prompt/prompt.go index e7d75cc794..397fc4296e 100644 --- a/cmd/prompt/prompt.go +++ b/cmd/prompt/prompt.go @@ -31,7 +31,11 @@ import ( //go:generate mockgen -destination=./prompt_mock.go -self_package "github.com/docker/compose/v5/pkg/prompt" -package=prompt . UI -var errInterrupt = errors.New("interrupt") +// ErrInterrupt is returned by an interactive prompt when the user presses +// Ctrl+C. The terminal being in raw mode, no SIGINT is ever delivered: this +// error is the only interrupt signal callers get, and the command runner +// maps it to the conventional 130 exit status like a real SIGINT. +var ErrInterrupt = errors.New("interrupt") // UI - prompt user input type UI interface { @@ -59,13 +63,8 @@ func (u User) Confirm(message string, defaultValue bool) (bool, error) { } defer u.stdin.RestoreTerminal() - prompt := " [y/N]: " - if defaultValue { - prompt = " [Y/n]: " - } - for { - _, _ = fmt.Fprint(u.stdout, message+prompt) + _, _ = fmt.Fprint(u.stdout, message+confirmHint(defaultValue)) answer, err := readLine(u.reader, u.stdout) if err != nil { @@ -83,6 +82,15 @@ func (u User) Confirm(message string, defaultValue bool) (bool, error) { } } +// confirmHint renders the answer hint appended to every confirmation +// message, the capitalized letter marking the default. +func confirmHint(defaultValue bool) string { + if defaultValue { + return " [Y/n]: " + } + return " [y/N]: " +} + func readLine(in io.RuneReader, out io.Writer) (string, error) { var line []rune @@ -95,7 +103,7 @@ func readLine(in io.RuneReader, out io.Writer) (string, error) { switch ch { case 3: // Ctrl+C _, _ = fmt.Fprint(out, "\r\n") - return "", errInterrupt + return "", ErrInterrupt case 4: // Ctrl+D return "", io.EOF @@ -104,12 +112,19 @@ func readLine(in io.RuneReader, out io.Writer) (string, error) { _, _ = fmt.Fprint(out, "\r\n") return string(line), nil - case 127: // Backspace + case 8, 127: // Backspace (^H on some terminals, DEL on most) if len(line) > 0 { line = line[:len(line)-1] _, _ = fmt.Fprint(out, "\b \b") } + case 27: // ESC: swallow the whole escape sequence (arrow keys, F-keys) + // so its printable tail ("[A"...) is neither echoed nor taken as + // input — the raw terminal delivers those as bytes, not events + if err := discardEscapeSequence(in); err != nil { + return "", err + } + default: if unicode.IsControl(ch) { continue @@ -120,6 +135,35 @@ func readLine(in io.RuneReader, out io.Writer) (string, error) { } } +// discardEscapeSequence consumes the remainder of an ANSI escape sequence +// whose ESC has just been read: a CSI sequence ("ESC [", parameters, one +// final byte in 0x40-0x7E) or an SS3 one ("ESC O", one byte). A bare ESC +// followed by anything else swallows that single rune, close enough for a +// yes/no prompt. +func discardEscapeSequence(in io.RuneReader) error { + ch, _, err := in.ReadRune() + if err != nil { + return err + } + switch ch { + case '[': // CSI: parameter/intermediate bytes then one final byte + for { + ch, _, err = in.ReadRune() + if err != nil { + return err + } + if ch >= 0x40 && ch <= 0x7E { + return nil + } + } + case 'O': // SS3 (application-mode cursor/function keys): one byte + _, _, err = in.ReadRune() + return err + default: + return nil + } +} + // Pipe - aggregates prompt methods type Pipe struct { stdout io.Writer @@ -128,7 +172,9 @@ type Pipe struct { // Confirm asks for yes or no input func (u Pipe) Confirm(message string, defaultValue bool) (bool, error) { - _, _ = fmt.Fprint(u.stdout, message) + // same hint as the interactive prompt: the message reaching a log or a + // piped consumer documents what was asked and what the default was + _, _ = fmt.Fprint(u.stdout, message+confirmHint(defaultValue)) var answer string _, _ = fmt.Fscanln(u.stdin, &answer) return utils.StringToBool(answer), nil diff --git a/cmd/prompt/prompt_test.go b/cmd/prompt/prompt_test.go index 1072d5e961..063cdcf2c4 100644 --- a/cmd/prompt/prompt_test.go +++ b/cmd/prompt/prompt_test.go @@ -160,6 +160,47 @@ func TestUserConfirmSequential(t *testing.T) { } } +// Arrow keys arrive as raw escape sequences in raw mode: the whole sequence +// must be swallowed — neither echoed nor taken as answer characters. +func TestUserConfirmIgnoresArrowKeys(t *testing.T) { + ptmx, user := newTestUser(t) + + done := make(chan struct { + answer bool + err error + }, 1) + go func() { + answer, err := user.Confirm("Continue?", false) + done <- struct { + answer bool + err error + }{answer, err} + }() + + readUntil(t, ptmx, "Continue? [y/N]: ") + + // Up arrow (CSI), Home in application mode (SS3), then a real answer. + _, err := ptmx.Write([]byte("\x1b[A\x1bOH y\r")) + assert.NilError(t, err) + + select { + case result := <-done: + assert.NilError(t, result.err) + assert.Assert(t, result.answer, "the arrow-key bytes must not corrupt the answer") + case <-time.After(time.Second): + t.Fatal("timed out waiting for prompt to return") + } +} + +// Both backspace encodings erase: DEL (most terminals) and ^H (some +// terminals, legacy Windows console). +func TestReadLineBackspaceVariants(t *testing.T) { + var stdout bytes.Buffer + line, err := readLine(bufio.NewReader(strings.NewReader("nx\x7fy\x08o\r")), &stdout) + assert.NilError(t, err) + assert.Equal(t, line, "no") +} + func TestReadLineInterrupt(t *testing.T) { var stdout bytes.Buffer @@ -168,7 +209,7 @@ func TestReadLineInterrupt(t *testing.T) { &stdout, ) - assert.ErrorIs(t, err, errInterrupt) + assert.ErrorIs(t, err, ErrInterrupt) assert.Equal(t, stdout.String(), "\r\n") }