Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cmd/compose/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
}
Expand Down
22 changes: 22 additions & 0 deletions cmd/compose/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
4 changes: 2 additions & 2 deletions cmd/compose/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]: ",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait; did I mess this up? (if so; why didn't CI fail?)

},
{
name: "user rejects remote includes",
Expand All @@ -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]: ",
},
}

Expand Down
66 changes: 56 additions & 10 deletions cmd/prompt/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment on lines -34 to +38

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When looking at the original PR, I was actually considering if we wanted to have context-cancellation somewhere.

(I kept the interrupt error because the old library printed that, but I think it's not that common to print something normally, e.g.

docker system prun
...
Are you sure you want to continue? [y/N] ^C


// UI - prompt user input
type UI interface {
Expand Down Expand Up @@ -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))
Comment on lines -68 to +67

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting that it added a helper (thought from your Slack thread that it generally preferred just raw inline)?


answer, err := readLine(u.reader, u.stdout)
if err != nil {
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
43 changes: 42 additions & 1 deletion cmd/prompt/prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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")
}

Expand Down
Loading