From 29ff1697f17378054a4fe6219a8d3a2b43728f1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Gro=C3=9Fmann?= Date: Wed, 7 Oct 2026 10:21:16 +0200 Subject: [PATCH 1/9] feat(pass): prompt for the secret when STDIN is a terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docker pass set ` used to block on STDIN whenever no value was given, echoing whatever the user typed. It now detects an interactive terminal and shows a masked prompt instead, so the value neither lands in shell history nor on screen, and nobody has to pipe it from a file. Pipes and redirects still read STDIN to the end as before. The prompt is a small raw-mode line reader on top of x/term. Raw mode is entered with tcsetattr(TCSAFLUSH), as getpass(3), sudo and readpassphrase do, so input typed before the prompt appeared, which the still-cooked tty echoed in plaintext, is discarded rather than taken as the secret. Printable runes and tab are appended and echoed as '*', Backspace removes the last rune, ctrl+u clears the line, and Enter or a bare LF (ctrl+j) ends it, as with getpass(3). An empty Enter is an error, and so is input ending before Enter: Ctrl-D, or Ctrl-Z on a Windows console, which the console reader in os reports as EOF. Unlike getpass(3), a line that was not entered is never stored. Ctrl-C, which raw mode delivers as a key press rather than a signal, is reported as context.Canceled so the root exits 130 silently. Cancelling the context, as the root does on a signal, ends the read loop so its buffers are zeroed, restores the terminal and returns; the root ends the prompt line when it reports the signal. The tty is read only once poll(2) reports input, so no read is left in flight when the prompt returns: a blocking tty read cannot be cancelled, and one left behind would take the line typed next, meant for the shell, while the store is still busy. A pipe written on cancel is polled alongside. On Windows the console handle is waited on with WaitForMultipleObjects and its records taken with ReadConsoleInput, since any unread record, a key release among them, signals the handle while ReadConsole would swallow it and block again. Escape sequences are skipped whole, cursor keys, mouse reports and stray terminal replies among them, so they never leak into the value; a control byte inside one ends it, so Enter and Ctrl-C get through regardless. Each byte of a sequence gets 50 ms to arrive, as with vim's ttimeout, and a longer gap ends the burst ESC began. ESC alone is the Escape key and ignored, though a '[' or 'O' within 500 ms of it is still taken for the tail of its sequence, split by a slow link. Printable bytes that complete no sequence or trail one within the burst are an error rather than dropped: Alt+h, Escape coalesced with typing by ssh latency, or a chord like Alt+[ that would otherwise park the reader inside the sequence parser eating the keys typed next all look alike, and a masked prompt cannot show which character went missing. A paste has no such timeout, since it streams at the pace of the link. The read buffer and the collected runes are zeroed when the prompt returns. Pastes are taken through bracketed paste mode only; the prompt never reads the clipboard. A pasted trailing newline is trimmed, and a value spanning several lines, or holding control characters other than tab, is rejected with a pointer to STDIN rather than stored mangled. So is a byte that is not UTF-8, typed or pasted, rather than left out of the value with nothing but a missing '*' to show for it. Ctrl-C cancels inside a paste too, the way out should its end marker never arrive. When the terminal does not bracket pastes, a multi-line paste arrives as keystrokes; input trailing the line end within 50 ms, faster than anyone types, gives it away whether or not a read ended at the line end, and it is rejected rather than stored as its first line. Type-ahead that a busy host or ssh coalesced with Enter looks the same, so that error names what was seen, input past the line end, not a paste. The terminal is restored and the input still queued in it discarded in one tcsetattr(TCSAFLUSH) ioctl (TIOCSETAF on Darwin, TCSETSF on Linux; SetConsoleMode then FlushConsoleInputBuffer on Windows), as getpass(3) does, deferred so that a panic restores too; a restore that fails is an error even after a value was read, as a terminal left raw must not pass for success. Restoring first and flushing second would leave a window on BSD-derived ttys: turning ICANON back on sets PENDIN, and the next byte to arrive has the queued remainder retyped with ECHO on, printing the rest of the paste in plaintext. Pastes larger than the pty input queue can still reach the shell, as they do with sudo and ssh prompts. Cobra hands the subcommands docker's stream wrappers, which hide the terminal file behind File(); unwrapFile reaches it to switch the terminal to raw mode. Signed-off-by: Johannes Großmann --- .github/workflows/unittests.yml | 38 +- README.md | 5 +- plugin/go.mod | 2 +- plugin/go.sum | 4 +- plugins/credentialhelper/go.mod | 2 +- plugins/credentialhelper/go.sum | 4 +- plugins/pass/commands/prompt.go | 533 ++++++++++++++ plugins/pass/commands/prompt_darwin.go | 19 + plugins/pass/commands/prompt_linux.go | 19 + plugins/pass/commands/prompt_test.go | 665 ++++++++++++++++++ plugins/pass/commands/prompt_unix.go | 164 +++++ plugins/pass/commands/prompt_unix_test.go | 174 +++++ plugins/pass/commands/prompt_windows.go | 208 ++++++ plugins/pass/commands/prompt_windows_test.go | 115 +++ plugins/pass/commands/set.go | 49 +- plugins/pass/commands/set_example.md | 29 +- plugins/pass/commands/set_long.md | 21 +- plugins/pass/go.mod | 3 +- plugins/pass/go.sum | 6 +- store/go.mod | 2 +- store/go.sum | 4 +- .../github.com/charmbracelet/x/term/LICENSE | 21 + .../github.com/charmbracelet/x/term/term.go | 51 ++ .../charmbracelet/x/term/term_other.go | 39 + .../charmbracelet/x/term/term_plan9.go | 118 ++++ .../charmbracelet/x/term/term_unix.go | 96 +++ .../charmbracelet/x/term/term_unix_bsd.go | 11 + .../charmbracelet/x/term/term_unix_other.go | 11 + .../charmbracelet/x/term/term_windows.go | 87 +++ .../charmbracelet/x/term/terminal.go | 12 + .../github.com/charmbracelet/x/term/util.go | 47 ++ vendor/golang.org/x/sys/unix/ztypes_linux.go | 76 ++ .../golang.org/x/sys/unix/ztypes_linux_386.go | 4 + .../x/sys/unix/ztypes_linux_amd64.go | 4 + .../golang.org/x/sys/unix/ztypes_linux_arm.go | 4 + .../x/sys/unix/ztypes_linux_arm64.go | 4 + .../x/sys/unix/ztypes_linux_loong64.go | 4 + .../x/sys/unix/ztypes_linux_mips.go | 4 + .../x/sys/unix/ztypes_linux_mips64.go | 4 + .../x/sys/unix/ztypes_linux_mips64le.go | 4 + .../x/sys/unix/ztypes_linux_mipsle.go | 4 + .../golang.org/x/sys/unix/ztypes_linux_ppc.go | 4 + .../x/sys/unix/ztypes_linux_ppc64.go | 4 + .../x/sys/unix/ztypes_linux_ppc64le.go | 4 + .../x/sys/unix/ztypes_linux_riscv64.go | 4 + .../x/sys/unix/ztypes_linux_s390x.go | 4 + .../x/sys/unix/ztypes_linux_sparc64.go | 4 + vendor/modules.txt | 5 +- x/go.mod | 2 +- x/go.sum | 4 +- 50 files changed, 2642 insertions(+), 64 deletions(-) create mode 100644 plugins/pass/commands/prompt.go create mode 100644 plugins/pass/commands/prompt_darwin.go create mode 100644 plugins/pass/commands/prompt_linux.go create mode 100644 plugins/pass/commands/prompt_test.go create mode 100644 plugins/pass/commands/prompt_unix.go create mode 100644 plugins/pass/commands/prompt_unix_test.go create mode 100644 plugins/pass/commands/prompt_windows.go create mode 100644 plugins/pass/commands/prompt_windows_test.go create mode 100644 vendor/github.com/charmbracelet/x/term/LICENSE create mode 100644 vendor/github.com/charmbracelet/x/term/term.go create mode 100644 vendor/github.com/charmbracelet/x/term/term_other.go create mode 100644 vendor/github.com/charmbracelet/x/term/term_plan9.go create mode 100644 vendor/github.com/charmbracelet/x/term/term_unix.go create mode 100644 vendor/github.com/charmbracelet/x/term/term_unix_bsd.go create mode 100644 vendor/github.com/charmbracelet/x/term/term_unix_other.go create mode 100644 vendor/github.com/charmbracelet/x/term/term_windows.go create mode 100644 vendor/github.com/charmbracelet/x/term/terminal.go create mode 100644 vendor/github.com/charmbracelet/x/term/util.go diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index abd31baf..0997429a 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -4,9 +4,11 @@ on: branches: - main pull_request: +permissions: + contents: read jobs: unit-tests: - name: Unit Tests + name: Linux runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -14,5 +16,37 @@ jobs: uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version-file: go.work - - name: Unit Tests + - name: Tests run: make unit-tests + + unit-tests-cross: + name: ${{ matrix.module }} (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + module: [plugins/pass] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Setup Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go.work + - name: Tests + run: go test -trimpath -race -v ./${{ matrix.module }}/... + env: + CGO_ENABLED: "1" + + unit-tests-result: + name: Unit Tests + needs: + - unit-tests + - unit-tests-cross + if: always() + runs-on: ubuntu-latest + steps: + - name: Check results + run: | + [ "${{ needs.unit-tests.result }}" = "success" ] || exit 1 + [ "${{ needs.unit-tests-cross.result }}" = "success" ] || exit 1 diff --git a/README.md b/README.md index 37af9773..b0de9159 100644 --- a/README.md +++ b/README.md @@ -94,9 +94,12 @@ You no longer need: Store the secret in your OS keychain: ```bash -# recommended: stdin keeps the secret out of the command line (visible in htop) and shell history +# recommended: a masked prompt keeps the secret out of the command line (visible in htop) and shell history docker pass set foo +# multi-line values, such as certificates, come from a file or a pipe +docker pass set my-cert < cert.pem + # or pass the value inline docker pass set foo=secret ``` diff --git a/plugin/go.mod b/plugin/go.mod index d1b00f58..36bb1af3 100644 --- a/plugin/go.mod +++ b/plugin/go.mod @@ -21,6 +21,6 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect golang.org/x/mod v0.36.0 // indirect - golang.org/x/sys v0.45.0 // indirect + golang.org/x/sys v0.46.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/plugin/go.sum b/plugin/go.sum index 72f0840f..b43e0a07 100644 --- a/plugin/go.sum +++ b/plugin/go.sum @@ -21,8 +21,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/plugins/credentialhelper/go.mod b/plugins/credentialhelper/go.mod index 7f8709b1..a00bac86 100644 --- a/plugins/credentialhelper/go.mod +++ b/plugins/credentialhelper/go.mod @@ -20,7 +20,7 @@ require ( github.com/hashicorp/yamux v0.1.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect golang.org/x/mod v0.36.0 // indirect - golang.org/x/sys v0.45.0 // indirect + golang.org/x/sys v0.46.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/plugins/credentialhelper/go.sum b/plugins/credentialhelper/go.sum index 3cfb8f49..43a67cec 100644 --- a/plugins/credentialhelper/go.sum +++ b/plugins/credentialhelper/go.sum @@ -24,8 +24,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/plugins/pass/commands/prompt.go b/plugins/pass/commands/prompt.go new file mode 100644 index 00000000..50172aee --- /dev/null +++ b/plugins/pass/commands/prompt.go @@ -0,0 +1,533 @@ +// Copyright 2026 Docker, Inc. +// +// 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 commands + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "time" + "unicode" + "unicode/utf8" + + secrets "github.com/docker/secrets-engine/store" +) + +var ( + errEmptyValue = errors.New("no value entered") + errMultilinePaste = errors.New("pasted value spans several lines; pipe it via STDIN instead") + errPastLineEnd = errors.New("input continued past the line end; pipe a multi-line value via STDIN instead") + errControlChars = errors.New("value contains control characters; pipe it via STDIN instead") + errInvalidUTF8 = errors.New("value is not valid UTF-8; pipe it via STDIN instead") + errSwallowedInput = errors.New("escape sequence or Alt chord swallowed part of the input; enter the value again") +) + +const ( + bell = 0x07 + ctrlC = 0x03 + ctrlD = 0x04 + ctrlU = 0x15 + esc = 0x1b + del = 0x7f // Backspace; some terminals send '\b' + + bracketedPasteOn = "\x1b[?2004h" + bracketedPasteOff = "\x1b[?2004l" + pasteStart = "200" // parameter of ESC[200~ + pasteEnd = "\x1b[201~" + + escTimeout = 50 * time.Millisecond + tailTimeout = 500 * time.Millisecond +) + +var ( + errEscapeKey = errors.New("escape key") + errChord = errors.New("escape chord") +) + +func unwrapFile(s any) (*os.File, bool) { + // docker's streams.In and streams.Out hide the file behind File(). + if d, wrapped := s.(interface{ File() (*os.File, bool) }); wrapped { + return d.File() + } + f, ok := s.(*os.File) + return f, ok +} + +type input interface { + io.Reader + wait(d time.Duration) (ready bool, err error) +} + +// secretFromPrompt reads one masked line from the terminal as the value for +// id. Raw mode is entered and left with TCSAFLUSH, as getpass(3) does, so +// echoed type-ahead is dropped and a paste's tail never reaches the shell. +// Printable runes echo as '*', Backspace and ctrl+u edit, Enter submits, +// Ctrl-C cancels with context.Canceled, Ctrl-D or EOF is io.ErrUnexpectedEOF, +// escape sequences and unbound control keys are skipped. Whatever else would +// alter the value in silence is an error: a chord that swallowed text, input +// trailing Enter, a paste spanning lines, control characters, invalid UTF-8. +// Cancelling ctx restores the terminal and returns ctx.Err(); a terminal that +// cannot be restored is an error even after a value was read. No read of the +// terminal outlives the prompt: the line typed next is the shell's. +func secretFromPrompt(ctx context.Context, in *os.File, out io.Writer, id secrets.ID) (s *setPayload, err error) { + src, err := newTerminalInput(ctx, in) + if err != nil { + return nil, err + } + defer src.close() + state, err := enterRaw(in) + if err != nil { + return nil, err + } + defer func() { + eol := "\r\n" + if ctx.Err() != nil { + eol = "" // the root ends the line on a signal + } + _, _ = io.WriteString(out, bracketedPasteOff+eol) + if rerr := restoreTerminal(in, state); rerr != nil && err == nil { + s, err = nil, fmt.Errorf("restoring the terminal: %w; run reset to recover it", rerr) + } + }() + _, _ = fmt.Fprintf(out, "Enter secret for %s: %s", id.String(), bracketedPasteOn) + val, err := readSecretLine(src, out) + if err != nil { + return nil, err + } + return &setPayload{val: val}, nil +} + +func readSecretLine(in input, echo io.Writer) (string, error) { + lr := newLineReader(in) + defer lr.zero() + ed := &lineEditor{echo: echo} + defer ed.zero() + for { + r, err := lr.readRune() + switch { + case errors.Is(err, io.EOF), err == nil && r == ctrlD: + return "", io.ErrUnexpectedEOF + case err != nil: + return "", err + case r == '\r' || r == '\n': + return ed.submit(lr) + case r == ctrlC: + return "", context.Canceled + case r == del || r == '\b': + ed.backspace() + case r == ctrlU: + ed.reset() + case r == esc: + if err := ed.escape(lr); err != nil { + return "", err + } + case isC1(r): // no key sends one: mangled text + return "", errControlChars + case isControlRune(r): + // unbound control key; tab is text + default: + ed.insert(r) + } + } +} + +type lineEditor struct { + val []rune + echo io.Writer +} + +func (ed *lineEditor) emit(s string) { _, _ = io.WriteString(ed.echo, s) } + +func (ed *lineEditor) insert(r rune) { + if len(ed.val) == cap(ed.val) { + // append would retire the full array as is: grow by hand and zero it + grown := make([]rune, len(ed.val), max(2*cap(ed.val), 64)) + copy(grown, ed.val) + clear(ed.val) + ed.val = grown + } + ed.val = append(ed.val, r) + ed.emit("*") +} + +func (ed *lineEditor) backspace() { + if n := len(ed.val); n > 0 { + ed.val[n-1] = 0 // not left behind in the backing array + ed.val = ed.val[:n-1] + ed.emit("\b \b") + } +} + +func (ed *lineEditor) reset() { + ed.emit(strings.Repeat("\b \b", len(ed.val))) + ed.zero() + ed.val = ed.val[:0] +} + +func (ed *lineEditor) escape(lr *lineReader) error { + paste, ok, err := lr.escape() + if err != nil || !ok { + return err + } + return ed.paste(paste) +} + +func (ed *lineEditor) paste(content []byte) error { + defer clear(content) + content = bytes.TrimRight(content, "\r\n") + switch { + case bytes.ContainsAny(content, "\r\n"): + return errMultilinePaste + case bytes.IndexFunc(content, isControlRune) >= 0: + return errControlChars + case !utf8.Valid(content): + return errInvalidUTF8 + } + for len(content) > 0 { + r, size := utf8.DecodeRune(content) + content = content[size:] + ed.insert(r) + } + return nil +} + +func isControlRune(r rune) bool { return r != '\t' && unicode.IsControl(r) } + +func isC1(r rune) bool { return 0x80 <= r && r <= 0x9f } + +func (ed *lineEditor) submit(lr *lineReader) (string, error) { + more, err := lr.trailing() + switch { + case err != nil: + return "", err + case more: + return "", errPastLineEnd + case len(ed.val) == 0: + return "", errEmptyValue + } + return string(ed.val), nil +} + +// zero clears the runes collected, the backing array whole: a rune dropped +// since the last growth may sit past the length. +func (ed *lineEditor) zero() { clear(ed.val[:cap(ed.val)]) } + +type lineReader struct { + src input + buf [4096]byte + r, w int + // escapeAt is when a lone ESC last passed for the Escape key. + escapeAt time.Time + // tail is set when the ESC readRune last returned stood for a '[' or + // 'O' taken for that ESC's late tail. + tail bool +} + +func newLineReader(src input) *lineReader { return &lineReader{src: src} } + +func (lr *lineReader) zero() { + clear(lr.buf[:]) + lr.r, lr.w = 0, 0 +} + +// trailing reports input other than line breaks within escTimeout of the +// line end. A Ctrl-C among it cancels, as it does everywhere else. +func (lr *lineReader) trailing() (bool, error) { + deadline := time.Now().Add(escTimeout) + for { + rest := lr.buf[lr.r:lr.w] + switch { + case bytes.IndexByte(rest, ctrlC) >= 0: + return false, context.Canceled + case len(bytes.Trim(rest, "\r\n")) > 0: + return true, nil + } + wait := time.Until(deadline) + if wait <= 0 { + return false, nil + } + more, err := lr.fill(wait) + if err != nil && !errors.Is(err, io.EOF) { + return false, err + } + if !more { + return false, nil + } + } +} + +// fill awaits input for wait, without limit when wait <= 0, and reads what +// came. It reports false when the wait ran out, with no read left in flight. +func (lr *lineReader) fill(wait time.Duration) (bool, error) { + if lr.r > 0 { + lr.w = copy(lr.buf[:], lr.buf[lr.r:lr.w]) + lr.r = 0 + } + ready, err := lr.src.wait(wait) + if err != nil || !ready { + return false, err + } + n, err := lr.src.Read(lr.buf[lr.w:]) + lr.w += n + switch { + case n > 0: + return true, nil + case err == nil: + return false, io.ErrNoProgress + default: + return false, err + } +} + +func (lr *lineReader) readByte(wait time.Duration) (byte, error) { + if lr.r == lr.w { + more, err := lr.fill(wait) + switch { + case errors.Is(err, io.EOF): + return 0, io.ErrUnexpectedEOF + case err != nil: + return 0, err + case !more: + return 0, errChord + } + } + b := lr.buf[lr.r] + lr.r++ + return b, nil +} + +func (lr *lineReader) decodeRune() (rune, int, error) { + for !utf8.FullRune(lr.buf[lr.r:lr.w]) { + var wait time.Duration + if lr.r < lr.w { + wait = escTimeout + } + more, err := lr.fill(wait) + if err != nil { + return 0, 0, err + } + if !more { + return utf8.RuneError, 1, nil + } + } + r, size := utf8.DecodeRune(lr.buf[lr.r:lr.w]) + return r, size, nil +} + +// readRune returns the next rune. Invalid UTF-8 is errInvalidUTF8, unless a +// Ctrl-C queued behind it cancels. A '[' or 'O' within tailTimeout of a lone +// ESC is its sequence's tail and comes back as that ESC, unconsumed, with +// lr.tail set: escape then takes only a sequence a key sends, as '[' or 'O' +// typed after Escape would otherwise pass for one and be lost in silence. +func (lr *lineReader) readRune() (rune, error) { + r, size, err := lr.decodeRune() + if err != nil { + return 0, err + } + lr.tail = (r == '[' || r == 'O') && time.Since(lr.escapeAt) < tailTimeout + lr.escapeAt = time.Time{} + if lr.tail { + return esc, nil + } + lr.r += size + if r == utf8.RuneError && size == 1 { + if bytes.IndexByte(lr.buf[lr.r:lr.w], ctrlC) >= 0 { + return 0, context.Canceled + } + return 0, errInvalidUTF8 + } + return r, nil +} + +func (lr *lineReader) unreadByte() { lr.r-- } + +// escape parses the burst after ESC: sequences are skipped, a bracketed paste +// is returned with ok set, ESC alone is the Escape key. A control byte ends +// the burst and stays unread, so Enter and Ctrl-C get through. Printable +// bytes that complete no sequence or trail one are errSwallowedInput, as is +// a late tail's sequence that no key sends. +func (lr *lineReader) escape() (paste []byte, ok bool, err error) { + tail := lr.tail + for { + paste, ok, err = lr.sequence(tail) + tail = false // any sequence next in the burst brings its own ESC + switch { + case errors.Is(err, errEscapeKey): + lr.escapeAt = time.Now() + return nil, false, nil + case errors.Is(err, errChord): + return nil, false, errSwallowedInput + case err != nil || ok: // a paste is unmistakable: text may follow it + return paste, ok, err + } + b, err := lr.readByte(escTimeout) + switch { + case errors.Is(err, errChord): // a gap ends the burst + return nil, false, nil + case err != nil: + return nil, false, err + case b == esc: // the next sequence + case isControl(b): + lr.unreadByte() + return nil, false, nil + default: + return nil, false, errSwallowedInput + } + } +} + +// sequence parses one sequence after ESC: errEscapeKey for ESC alone or +// before a control key, errChord for bytes that complete no sequence. With +// tail set, the sequence must be one a key sends. +func (lr *lineReader) sequence(tail bool) (paste []byte, ok bool, err error) { + for { + b, err := lr.readByte(escTimeout) + switch { + case errors.Is(err, errChord): // nothing came: the Escape key + return nil, false, errEscapeKey + case err != nil: + return nil, false, err + case b == esc: // rxvt sends alt+arrow as ESC ESC [ A + continue + case isControl(b): // Escape then a key + lr.unreadByte() + return nil, false, errEscapeKey + case b == '[': + return lr.csi(tail) + case b == 'O': // SS3: one more byte + return nil, false, lr.skipFinal(tail) + case b == ']', b == 'P', b == '_', b == '^', b == 'X': + // OSC ends with BEL or ST; DCS, APC, PM and SOS with ST + again, err := lr.skipString(b == ']') + if err != nil || !again { + return nil, false, err + } + // cut short by a new sequence: parse it + default: // alt+key: a swallowed rune + return nil, false, errChord + } + } +} + +func (lr *lineReader) csi(tail bool) (paste []byte, ok bool, err error) { + var params []byte + for { + c, err := lr.readByte(escTimeout) + switch { + case err != nil: + return nil, false, err + case c == '[' && len(params) == 0: // Linux console F1-F5: ESC [ [ x + return nil, false, lr.skipFinal(tail) + case c >= 0x20 && c <= 0x3f: // parameter and intermediate bytes + params = append(params, c) + case c < 0x40 || c > 0x7e: // no sequence holds this byte + return nil, false, lr.cut(c) + case tail && !keyFinal(c): // no key sends this: typed after Escape + return nil, false, errSwallowedInput + case c == '~' && string(params) == pasteStart: + paste, err = lr.readPaste() + return paste, err == nil, err + case c == 'M' && len(params) == 0: // X10 mouse report + return nil, false, lr.skipMouseReport() + default: // final byte + return nil, false, nil + } + } +} + +func (lr *lineReader) skipFinal(tail bool) error { + c, err := lr.readByte(escTimeout) + switch { + case err != nil: + return err + case isControl(c): + return lr.cut(c) + case tail && !keyFinal(c): // no key sends this: typed after Escape + return errSwallowedInput + } + return nil +} + +func keyFinal(c byte) bool { + return 'A' <= c && c <= 'H' || 'P' <= c && c <= 'S' || c == 'Z' || c == '~' || 'a' <= c && c <= 'd' +} + +func (lr *lineReader) skipMouseReport() error { + for range 3 { + c, err := lr.readByte(escTimeout) + if err != nil { + return err + } + if isControl(c) { + return lr.cut(c) + } + } + return nil +} + +func (lr *lineReader) skipString(bel bool) (newSequence bool, err error) { + for { + c, err := lr.readByte(escTimeout) + switch { + case err != nil: + return false, err + case c == esc: + d, err := lr.readByte(escTimeout) + if err != nil { + return false, err + } + if d == '\\' { + return false, nil // ST + } + lr.unreadByte() + return true, nil + case c == bell && bel: + return false, nil + case isControl(c): + return false, lr.cut(c) + } + } +} + +func (lr *lineReader) cut(c byte) error { + if c == ctrlC { + return context.Canceled + } + lr.unreadByte() + return errChord +} + +func isControl(b byte) bool { return b < 0x20 || b == del } + +func (lr *lineReader) readPaste() ([]byte, error) { + var out []byte + for !bytes.HasSuffix(out, []byte(pasteEnd)) { + b, err := lr.readByte(0) + switch { + case err != nil: + clear(out) + return nil, err + case b == ctrlC: + clear(out) + return nil, context.Canceled + } + out = append(out, b) + } + return out[:len(out)-len(pasteEnd)], nil +} diff --git a/plugins/pass/commands/prompt_darwin.go b/plugins/pass/commands/prompt_darwin.go new file mode 100644 index 00000000..101767d3 --- /dev/null +++ b/plugins/pass/commands/prompt_darwin.go @@ -0,0 +1,19 @@ +// Copyright 2026 Docker, Inc. +// +// 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 commands + +import "golang.org/x/sys/unix" + +const setTermiosFlush = unix.TIOCSETAF diff --git a/plugins/pass/commands/prompt_linux.go b/plugins/pass/commands/prompt_linux.go new file mode 100644 index 00000000..763b26f9 --- /dev/null +++ b/plugins/pass/commands/prompt_linux.go @@ -0,0 +1,19 @@ +// Copyright 2026 Docker, Inc. +// +// 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 commands + +import "golang.org/x/sys/unix" + +const setTermiosFlush = unix.TCSETSF diff --git a/plugins/pass/commands/prompt_test.go b/plugins/pass/commands/prompt_test.go new file mode 100644 index 00000000..1dc6e983 --- /dev/null +++ b/plugins/pass/commands/prompt_test.go @@ -0,0 +1,665 @@ +// Copyright 2026 Docker, Inc. +// +// 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 commands + +import ( + "bytes" + "context" + "io" + "os" + "strings" + "testing" + "testing/iotest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// read runs the prompt's line reader over in and returns the value, the +// masked echo, and the error. +func read(t *testing.T, in io.Reader) (string, string, error) { + t.Helper() + var echo bytes.Buffer + val, err := readSecretLine(testInput(t.Context(), in), &echo) + return val, echo.String(), err +} + +func Test_readSecretLine(t *testing.T) { + t.Parallel() + t.Run("enter submits and masks the echo", func(t *testing.T) { + t.Parallel() + val, echo, err := read(t, strings.NewReader("hunter2\r")) + require.NoError(t, err) + assert.Equal(t, "hunter2", val) + assert.Equal(t, "*******", echo) + }) + t.Run("bare LF submits like enter", func(t *testing.T) { + t.Parallel() + val, _, err := read(t, strings.NewReader("hunter2\n")) + require.NoError(t, err) + assert.Equal(t, "hunter2", val) + }) + t.Run("EOF before enter does not submit", func(t *testing.T) { + t.Parallel() + val, _, err := read(t, strings.NewReader("hunter2")) + assert.ErrorIs(t, err, io.ErrUnexpectedEOF) + assert.Empty(t, val) + }) + t.Run("ctrl+d ends the prompt without a value", func(t *testing.T) { + t.Parallel() + // On a Windows console Ctrl-Z arrives as EOF and ends it the same way. + val, _, err := read(t, strings.NewReader("hunter2\x04\r")) + assert.ErrorIs(t, err, io.ErrUnexpectedEOF) + assert.Empty(t, val) + }) + t.Run("cancelling the context ends the read", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(t.Context()) + pr, pw := io.Pipe() // never written to: the read blocks like a quiet tty + t.Cleanup(func() { _ = pw.Close() }) + go func() { + time.Sleep(20 * time.Millisecond) + cancel() + }() + var echo bytes.Buffer + _, err := readSecretLine(testInput(ctx, pr), &echo) + assert.ErrorIs(t, err, context.Canceled) + assert.Empty(t, echo.String()) + }) + t.Run("enter on empty input errors", func(t *testing.T) { + t.Parallel() + _, _, err := read(t, strings.NewReader("\r")) + assert.ErrorIs(t, err, errEmptyValue) + }) + t.Run("ctrl+c cancels", func(t *testing.T) { + t.Parallel() + _, _, err := read(t, strings.NewReader("hun\x03ter2\r")) + assert.ErrorIs(t, err, context.Canceled) + }) + t.Run("backspace removes one rune", func(t *testing.T) { + t.Parallel() + val, echo, err := read(t, strings.NewReader("pä\x7fa\bs\r")) + require.NoError(t, err) + assert.Equal(t, "ps", val) + assert.Equal(t, "**\b \b*\b \b*", echo) + }) + t.Run("backspace on empty input does nothing", func(t *testing.T) { + t.Parallel() + val, echo, err := read(t, strings.NewReader("\x7fa\r")) + require.NoError(t, err) + assert.Equal(t, "a", val) + assert.Equal(t, "*", echo) + }) + t.Run("ctrl+u clears the line", func(t *testing.T) { + t.Parallel() + val, echo, err := read(t, strings.NewReader("abc\x15d\r")) + require.NoError(t, err) + assert.Equal(t, "d", val) + assert.Equal(t, "***\b \b\b \b\b \b*", echo) + }) + t.Run("escape sequences are skipped", func(t *testing.T) { + t.Parallel() + // Left arrow (CSI), Home (SS3), F5 (CSI with parameters), SGR + // mouse report, cursor position report; rxvt alt+up (ESC ESC [ + // A), Linux console F1 (ESC [ [ A), X10 mouse report (ESC [ M and + // three bytes), OSC replies ended by BEL and by ST, a DCS reply; + // an OSC reply cut short by an arrow key. Typing resumes after a + // gap. + for _, seq := range []string{ + "\x1b[D", "\x1bOH", "\x1b[15~", "\x1b[<0;10;20M", "\x1b[12;40R", + "\x1b\x1b[A", "\x1b[[A", "\x1b[M !!", "\x1b]11;rgb:0000/0000/0000\x07", "\x1b]52;c;aGVsbG8=\x1b\\", "\x1bP>|xterm(380)\x1b\\", + "\x1b]11;rgb\x1b[D", + } { + t.Run(seq, func(t *testing.T) { + t.Parallel() + in := &chunkReader{chunks: []chunk{{data: "a" + seq}, {delay: 6 * escTimeout, data: "b\r"}}} + val, echo, err := read(t, in) + require.NoError(t, err) + assert.Equal(t, "ab", val) + assert.Equal(t, "**", echo) + }) + } + }) + t.Run("a control string cut short by a paste", func(t *testing.T) { + t.Parallel() + // A DCS reply without terminator, then a bracketed paste. + val, echo, err := read(t, strings.NewReader("\x1bP>|xterm\x1b[200~b\x1b[201~\r")) + require.NoError(t, err) + assert.Equal(t, "b", val) + assert.Equal(t, "*", echo) + }) + t.Run("text swallowed by an escape is an error", func(t *testing.T) { + t.Parallel() + // Alt+h, or Escape coalesced with typing by ssh latency; the same + // where the text opens a CSI or an SS3; alt+ä; alt with a rune cut + // short; an arrow key with typing right behind it. + for _, in := range []string{"\x1bhunter2\r", "\x1b[hunter2\r", "\x1bOpenSesame\r", "a\x1bäb\r", "\x1b\xc3", "a\x1b[Dab\r"} { + _, _, err := read(t, strings.NewReader(in)) + assert.ErrorIs(t, err, errSwallowedInput, "%q", in) + } + }) + t.Run("a sequence cut short by a key is an error", func(t *testing.T) { + t.Parallel() + // Enter, Backspace or Ctrl-D right behind Alt+[ or the like. + for _, in := range []string{"a\x1b[\r", "a\x1b[M\r", "a\x1bO\x7f", "a\x1b]11;rgb\r", "a\x1b[12;\x04"} { + _, _, err := read(t, strings.NewReader(in)) + assert.ErrorIs(t, err, errSwallowedInput, "%q", in) + } + }) + t.Run("a chord cut short by a gap is an error", func(t *testing.T) { + t.Parallel() + // Alt+[ opens a CSI, Alt+Shift+O an SS3, Alt+Shift+P a DCS and + // Alt+] an OSC; a CSI with parameters and an X10 mouse report cut + // short. Nothing follows within escTimeout. + for _, chord := range []string{"\x1b[", "\x1bO", "\x1bP", "\x1b]", "\x1b[12;", "\x1b[M "} { + t.Run(chord, func(t *testing.T) { + t.Parallel() + pr, pw := io.Pipe() // nothing follows: the read blocks like a quiet tty + t.Cleanup(func() { _ = pw.Close() }) + go func() { _, _ = pw.Write([]byte(chord)) }() + _, _, err := read(t, pr) + assert.ErrorIs(t, err, errSwallowedInput) + }) + } + }) + t.Run("escape then enter submits", func(t *testing.T) { + t.Parallel() + val, _, err := read(t, strings.NewReader("abc\x1b\r")) + require.NoError(t, err) + assert.Equal(t, "abc", val) + }) + t.Run("ctrl+c ends a sequence cut short", func(t *testing.T) { + t.Parallel() + for _, in := range []string{"\x1b[\x03", "\x1b[12;\x03", "\x1b[[\x03", "\x1bO\x03", "\x1b]11;rgb\x03", "\x1bP>|xterm\x03", "\x1b[M \x03"} { + _, _, err := read(t, strings.NewReader(in)) + assert.ErrorIs(t, err, context.Canceled, "%q", in) + } + }) + t.Run("a lone escape is ignored", func(t *testing.T) { + t.Parallel() + // Nothing follows ESC within escTimeout: the Escape key. + in := &chunkReader{chunks: []chunk{{data: "\x1b"}, {delay: 6 * escTimeout, data: "a\r"}}} + val, _, err := read(t, in) + require.NoError(t, err) + assert.Equal(t, "a", val) + }) + t.Run("escape then enter in separate reads submits", func(t *testing.T) { + t.Parallel() + in := &chunkReader{chunks: []chunk{{data: "abc\x1b"}, {delay: 6 * escTimeout, data: "\r"}}} + val, _, err := read(t, in) + require.NoError(t, err) + assert.Equal(t, "abc", val) + }) + t.Run("a sequence split across reads is skipped whole", func(t *testing.T) { + t.Parallel() + // The rest arrives in the next read, within escTimeout: after ESC, + // or inside the sequence. + for _, chunks := range [][]chunk{ + {{data: "\x1b"}, {data: "[D"}, {delay: 6 * escTimeout, data: "a\r"}}, + {{data: "\x1b[1;"}, {data: "5D"}, {delay: 6 * escTimeout, data: "a\r"}}, + } { + val, echo, err := read(t, &chunkReader{chunks: chunks}) + require.NoError(t, err, "%q", chunks) + assert.Equal(t, "a", val, "%q", chunks) + assert.Equal(t, "*", echo, "%q", chunks) + } + }) + t.Run("paste drops the trailing newline", func(t *testing.T) { + t.Parallel() + val, echo, err := read(t, strings.NewReader("\x1b[200~hunter2\r\n\x1b[201~\r")) + require.NoError(t, err) + assert.Equal(t, "hunter2", val) + assert.Equal(t, "*******", echo) + }) + t.Run("paste is kept verbatim", func(t *testing.T) { + t.Parallel() + val, _, err := read(t, strings.NewReader("\x1b[200~ a\tb \x1b[201~\r")) + require.NoError(t, err) + assert.Equal(t, " a\tb ", val) + }) + t.Run("multi-line paste is rejected", func(t *testing.T) { + t.Parallel() + _, _, err := read(t, strings.NewReader("\x1b[200~line1\nline2\n\x1b[201~")) + assert.ErrorIs(t, err, errMultilinePaste) + }) + t.Run("input behind the line end in the same read is rejected", func(t *testing.T) { + t.Parallel() + // A multi-line paste without bracketing, or type-ahead coalesced + // with Enter. + for _, in := range []string{"line1\rline2\r", "line1\nline2", "line1\r\nline2\r\n"} { + _, _, err := read(t, strings.NewReader(in)) + assert.ErrorIs(t, err, errPastLineEnd, "%q", in) + } + }) + t.Run("input right behind the line end in a later read is rejected too", func(t *testing.T) { + t.Parallel() + // A paste delivered line by line, or chunked at the line end; the + // wait outlasts a bare line break. + for _, chunks := range [][]chunk{ + {{data: "line1\r"}, {data: "line2\r"}}, + {{data: "line1\r"}, {data: "\n"}, {data: "line2\r\n"}}, + } { + _, _, err := read(t, &chunkReader{chunks: chunks}) + assert.ErrorIs(t, err, errPastLineEnd, "%q", chunks) + } + }) + t.Run("a quiet escTimeout after enter submits", func(t *testing.T) { + t.Parallel() + pr, pw := io.Pipe() // nothing follows Enter: the read blocks like a quiet tty + t.Cleanup(func() { _ = pw.Close() }) + go func() { _, _ = pw.Write([]byte("line1\r")) }() + val, _, err := read(t, pr) + require.NoError(t, err) + assert.Equal(t, "line1", val) + }) + t.Run("ctrl+c right behind enter cancels", func(t *testing.T) { + t.Parallel() + // Coalesced with Enter by ssh latency, or in the next read within + // escTimeout: Ctrl-C, not input past the line end. + _, _, err := read(t, strings.NewReader("secret\r\x03")) + assert.ErrorIs(t, err, context.Canceled) + _, _, err = read(t, &chunkReader{chunks: []chunk{{data: "secret\r"}, {data: "\x03"}}}) + assert.ErrorIs(t, err, context.Canceled) + }) + t.Run("line breaks after the line end are not a paste", func(t *testing.T) { + t.Parallel() + for _, in := range []string{"secret\r\n", "secret\r\r", "secret\n\r\n"} { + val, _, err := read(t, strings.NewReader(in)) + require.NoError(t, err, "%q", in) + assert.Equal(t, "secret", val, "%q", in) + } + }) + t.Run("paste with control characters is rejected", func(t *testing.T) { + t.Parallel() + for _, in := range []string{"\x1b[200~a\x1bb\x1b[201~\r", "\x1b[200~a\x7fb\x1b[201~\r", "\x1b[200~a\x00b\x1b[201~\r", "\x1b[200~a\u0085b\x1b[201~\r"} { + _, echo, err := read(t, strings.NewReader(in)) + assert.ErrorIs(t, err, errControlChars, "%q", in) + assert.Empty(t, echo, "%q", in) + } + }) + t.Run("ctrl+c inside a paste cancels", func(t *testing.T) { + t.Parallel() + // Also the way out of a paste whose end marker never comes. + for _, in := range []string{"\x1b[200~abc\x03def\x1b[201~\r", "\x1b[200~abc\x03"} { + _, _, err := read(t, strings.NewReader(in)) + assert.ErrorIs(t, err, context.Canceled, "%q", in) + } + }) + t.Run("paste longer than the read buffer", func(t *testing.T) { + t.Parallel() + long := strings.Repeat("x", 2*len(lineReader{}.buf)+1) + val, _, err := read(t, strings.NewReader("\x1b[200~"+long+"\x1b[201~\r")) + require.NoError(t, err) + assert.Equal(t, long, val) + }) + t.Run("a paste stalled for longer than escTimeout completes", func(t *testing.T) { + t.Parallel() + // A paste streams at the pace of the link: no timeout inside one. + in := &chunkReader{chunks: []chunk{{data: "\x1b[200~abc"}, {delay: 6 * escTimeout, data: "def\x1b[201~\r"}}} + val, _, err := read(t, in) + require.NoError(t, err) + assert.Equal(t, "abcdef", val) + }) + t.Run("EOF inside a paste errors", func(t *testing.T) { + t.Parallel() + _, _, err := read(t, strings.NewReader("\x1b[200~hunter2")) + assert.ErrorIs(t, err, io.ErrUnexpectedEOF) + }) + t.Run("EOF inside a sequence errors", func(t *testing.T) { + t.Parallel() + for _, in := range []string{"\x1b[", "\x1b[12;", "\x1b[[", "\x1bO", "\x1b]11;rgb", "\x1bP>|x", "\x1b[M "} { + _, _, err := read(t, strings.NewReader(in)) + assert.ErrorIs(t, err, io.ErrUnexpectedEOF, "%q", in) + } + }) + t.Run("ctrl+v and other control keys are ignored", func(t *testing.T) { + t.Parallel() + val, echo, err := read(t, strings.NewReader("a\x16\x02b\r")) + require.NoError(t, err) + assert.Equal(t, "ab", val) + assert.Equal(t, "**", echo) + }) + t.Run("a C1 control rune is an error", func(t *testing.T) { + t.Parallel() + // NEL and a Windows-1252 quote mark gone mojibake: no key sends + // one, so it is text gone wrong, typed or pasted unbracketed. + for _, in := range []string{"a\u0085b\r", "a\u0092b\r"} { + _, echo, err := read(t, strings.NewReader(in)) + assert.ErrorIs(t, err, errControlChars, "%q", in) + assert.Equal(t, "*", echo, "%q", in) + } + }) + t.Run("tab is kept, as in a paste", func(t *testing.T) { + t.Parallel() + val, echo, err := read(t, strings.NewReader("a\tb\r")) + require.NoError(t, err) + assert.Equal(t, "a\tb", val) + assert.Equal(t, "***", echo) + }) + t.Run("invalid UTF-8 is rejected", func(t *testing.T) { + t.Parallel() + // A stray byte, a lead byte without its continuation, before a key + // or before Enter, a lone continuation byte. + for _, in := range []string{"a\xffb\r", "a\xc3b\r", "a\xc3\r", "a\x80b\r"} { + val, echo, err := read(t, strings.NewReader(in)) + assert.ErrorIs(t, err, errInvalidUTF8, "%q", in) + assert.Empty(t, val, "%q", in) + assert.Equal(t, "*", echo, "%q", in) + } + }) + t.Run("a lead byte on its own is invalid", func(t *testing.T) { + t.Parallel() + // The rest of the rune never comes: a terminal sends a rune whole. + pr, pw := io.Pipe() + t.Cleanup(func() { _ = pw.Close() }) + go func() { _, _ = pw.Write([]byte("a\xc3")) }() + _, _, err := read(t, pr) + assert.ErrorIs(t, err, errInvalidUTF8) + }) + t.Run("ctrl+c behind a broken rune cancels", func(t *testing.T) { + t.Parallel() + for _, in := range []string{"a\xc3\x03", "a\xe2\x82\x03", "a\xc3b\x03"} { + _, _, err := read(t, strings.NewReader(in)) + assert.ErrorIs(t, err, context.Canceled, "%q", in) + } + // Also when it arrives in the next read, within escTimeout. + in := &chunkReader{chunks: []chunk{{data: "a\xc3"}, {data: "\x03"}}} + _, _, err := read(t, in) + assert.ErrorIs(t, err, context.Canceled) + }) + t.Run("the replacement character itself is kept", func(t *testing.T) { + t.Parallel() + val, _, err := read(t, strings.NewReader("�\r")) + require.NoError(t, err) + assert.Equal(t, "�", val) + }) + t.Run("paste with invalid UTF-8 is rejected", func(t *testing.T) { + t.Parallel() + _, echo, err := read(t, strings.NewReader("\x1b[200~a\xffb\x1b[201~\r")) + assert.ErrorIs(t, err, errInvalidUTF8) + assert.Empty(t, echo) + }) + t.Run("a rune split across reads", func(t *testing.T) { + t.Parallel() + val, echo, err := read(t, iotest.OneByteReader(strings.NewReader("ä\r"))) + require.NoError(t, err) + assert.Equal(t, "ä", val) + assert.Equal(t, "*", echo) + }) +} + +// Test_readSecretLine_lateTail covers a '[' or 'O' arriving within tailTimeout +// of a lone ESC. +func Test_readSecretLine_lateTail(t *testing.T) { + t.Parallel() + t.Run("a sequence tail late after its ESC is still skipped", func(t *testing.T) { + t.Parallel() + // A slow link splits ESC from "[D" for longer than escTimeout, yet + // within tailTimeout. + in := &chunkReader{chunks: []chunk{{data: "hunter2\x1b"}, {delay: 6 * escTimeout, data: "[D"}, {delay: 6 * escTimeout, data: "\r"}}} + val, echo, err := read(t, in) + require.NoError(t, err) + assert.Equal(t, "hunter2", val) + assert.Equal(t, "*******", echo) + }) + t.Run("a bracket typed well after escape is text", func(t *testing.T) { + t.Parallel() + in := &chunkReader{chunks: []chunk{{data: "\x1b"}, {delay: tailTimeout + 6*escTimeout, data: "[D\r"}}} + val, _, err := read(t, in) + require.NoError(t, err) + assert.Equal(t, "[D", val) + }) + t.Run("typing right behind a late tail is an error", func(t *testing.T) { + t.Parallel() + in := &chunkReader{chunks: []chunk{{data: "\x1b"}, {delay: 6 * escTimeout, data: "[Dab\r"}}} + _, _, err := read(t, in) + assert.ErrorIs(t, err, errSwallowedInput) + }) + t.Run("a late tail a key sends is skipped", func(t *testing.T) { + t.Parallel() + // Up, Home as SS3, F5, Shift-Tab, F1 as SS3 and on the Linux + // console, rxvt's shift+up, ctrl+left; a paste split from its ESC. + for _, tail := range []string{"[A", "OH", "[15~", "[Z", "OP", "[[A", "[a", "[1;5D", "[200~hunter2\x1b[201~"} { + t.Run(tail, func(t *testing.T) { + t.Parallel() + in := &chunkReader{chunks: []chunk{{data: "\x1b"}, {delay: 6 * escTimeout, data: tail}, {delay: 6 * escTimeout, data: "a\r"}}} + val, _, err := read(t, in) + require.NoError(t, err) + want := "a" + if strings.HasPrefix(tail, "[200~") { + want = "hunter2a" + } + assert.Equal(t, want, val) + }) + } + }) + t.Run("a late tail that no key sends is an error", func(t *testing.T) { + t.Parallel() + // Escape out of habit, then a value opening with 'O' or '[' whose + // second key ssh latency coalesces with the first and a gap follows: + // an SS3 or CSI no key sends. Also a mouse report, X10 and SGR, and + // a terminal reply split from their ESC, since they end the same way. + for _, tail := range []string{"Op", "[x", "[M !!", "[<0;10;20M", "[0n"} { + t.Run(tail, func(t *testing.T) { + t.Parallel() + in := &chunkReader{chunks: []chunk{{data: "abc\x1b"}, {delay: 6 * escTimeout, data: tail}, {delay: 6 * escTimeout, data: "enSesame\r"}}} + _, _, err := read(t, in) + assert.ErrorIs(t, err, errSwallowedInput) + }) + } + }) +} + +func Test_lineEditor_zero(t *testing.T) { + t.Parallel() + // A rune Backspace drops leaves no trace in the backing array, and + // neither do the runes ctrl+u clears or zero() ends with. + ed := &lineEditor{echo: io.Discard} + for _, r := range "hunter2x" { + ed.insert(r) + } + ed.backspace() + assert.Equal(t, "hunter2", string(ed.val)) + assert.NotContains(t, string(ed.val[:cap(ed.val)]), "x") + ed.reset() + assert.Empty(t, ed.val) + assert.Equal(t, make([]rune, cap(ed.val)), ed.val[:cap(ed.val)]) + for _, r := range "abc" { + ed.insert(r) + } + ed.zero() + assert.Equal(t, make([]rune, cap(ed.val)), ed.val[:cap(ed.val)]) + // An array the value outgrows is retired zeroed, not left to append. + ed = &lineEditor{echo: io.Discard} + for ed.insert('a'); len(ed.val) < cap(ed.val); { + ed.insert('a') + } + old := ed.val[:cap(ed.val)] + ed.insert('b') + assert.Equal(t, make([]rune, len(old)), old) + assert.Equal(t, strings.Repeat("a", len(old))+"b", string(ed.val)) +} + +type chunk struct { + delay time.Duration + data string +} + +// chunkReader returns one chunk per Read, after the chunk's delay, then EOF. +type chunkReader struct{ chunks []chunk } + +func (c *chunkReader) Read(p []byte) (int, error) { + if len(c.chunks) == 0 { + return 0, io.EOF + } + next := c.chunks[0] + c.chunks = c.chunks[1:] + time.Sleep(next.delay) + return copy(p, next.data), nil +} + +// readerInput makes an io.Reader the prompt's input for tests. A plain reader +// cannot be awaited, so a Read runs in a goroutine and the wait is on its +// result; a Read left behind when a test ends is the very thing +// terminalInput spares the terminal. +type readerInput struct { + ctx context.Context + r io.Reader + pending chan readResult + res readResult // handed over, not yet read + has bool +} + +type readResult struct { + data []byte + err error +} + +func testInput(ctx context.Context, r io.Reader) *readerInput { + return &readerInput{ctx: ctx, r: r} +} + +func (in *readerInput) wait(d time.Duration) (bool, error) { + if in.has { + return true, nil + } + if in.pending == nil { + ch := make(chan readResult, 1) + go func() { + data := make([]byte, 4096) + n, err := in.r.Read(data) + ch <- readResult{data: data[:n], err: err} + }() + in.pending = ch + } + var timeout <-chan time.Time + if d > 0 { + timeout = time.After(d) + } + select { + case in.res = <-in.pending: + in.pending, in.has = nil, true + return true, nil + case <-timeout: + return false, nil + case <-in.ctx.Done(): + return false, in.ctx.Err() + } +} + +func (in *readerInput) Read(p []byte) (int, error) { + for !in.has { + if _, err := in.wait(0); err != nil { + return 0, err + } + } + n := copy(p, in.res.data) + in.res.data = in.res.data[n:] + if len(in.res.data) > 0 { + return n, nil + } + in.has = false + return n, in.res.err +} + +// fakeInput is an input whose wait is scripted and whose reads are counted. +type fakeInput struct { + ready bool + err error + data string + reads int +} + +func (f *fakeInput) wait(time.Duration) (bool, error) { return f.ready, f.err } + +func (f *fakeInput) Read(p []byte) (int, error) { + f.reads++ + return copy(p, f.data), nil +} + +func Test_lineReader_fill(t *testing.T) { + t.Parallel() + t.Run("input awaited is read", func(t *testing.T) { + t.Parallel() + in := &fakeInput{ready: true, data: "hunter2"} + lr := newLineReader(in) + more, err := lr.fill(0) + require.NoError(t, err) + assert.True(t, more) + assert.Equal(t, "hunter2", string(lr.buf[lr.r:lr.w])) + }) + t.Run("a wait that runs out issues no read", func(t *testing.T) { + t.Parallel() + // Nothing may be left reading the terminal when the prompt returns: + // the line typed next is the shell's. + in := &fakeInput{data: "ls\r"} + lr := newLineReader(in) + more, err := lr.fill(escTimeout) + require.NoError(t, err) + assert.False(t, more) + assert.Zero(t, in.reads) + }) + t.Run("a wait ended by cancel issues no read", func(t *testing.T) { + t.Parallel() + in := &fakeInput{err: context.Canceled, data: "ls\r"} + lr := newLineReader(in) + _, err := lr.fill(0) + require.ErrorIs(t, err, context.Canceled) + assert.Zero(t, in.reads) + }) + t.Run("a read that returns nothing is an error", func(t *testing.T) { + t.Parallel() + lr := newLineReader(&fakeInput{ready: true}) + _, err := lr.fill(0) + assert.ErrorIs(t, err, io.ErrNoProgress) + }) +} + +func Test_unwrapFile(t *testing.T) { + t.Parallel() + f, err := os.Open(os.DevNull) + require.NoError(t, err) + t.Cleanup(func() { _ = f.Close() }) + t.Run("plain file", func(t *testing.T) { + t.Parallel() + got, ok := unwrapFile(f) + require.True(t, ok) + assert.Same(t, f, got) + }) + t.Run("docker stream wrapper", func(t *testing.T) { + t.Parallel() + got, ok := unwrapFile(fileWrapper{Reader: &bytes.Buffer{}, f: f}) + require.True(t, ok) + assert.Same(t, f, got) + }) + t.Run("wrapper without a file", func(t *testing.T) { + t.Parallel() + _, ok := unwrapFile(fileWrapper{Reader: &bytes.Buffer{}}) + assert.False(t, ok) + }) + t.Run("buffer", func(t *testing.T) { + t.Parallel() + _, ok := unwrapFile(&bytes.Buffer{}) + assert.False(t, ok) + }) +} + +type fileWrapper struct { + io.Reader + f *os.File +} + +func (w fileWrapper) File() (*os.File, bool) { return w.f, w.f != nil } diff --git a/plugins/pass/commands/prompt_unix.go b/plugins/pass/commands/prompt_unix.go new file mode 100644 index 00000000..459e1567 --- /dev/null +++ b/plugins/pass/commands/prompt_unix.go @@ -0,0 +1,164 @@ +// Copyright 2026 Docker, Inc. +// +// 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. + +//go:build !windows + +package commands + +import ( + "context" + "errors" + "os" + "sync" + "time" + + "github.com/charmbracelet/x/term" + "golang.org/x/sys/unix" +) + +func enterRaw(f *os.File) (*term.State, error) { + state, err := term.GetState(f.Fd()) + if err != nil { + return nil, err + } + raw := state.Termios + // cfmakeraw(3) + raw.Iflag &^= unix.IGNBRK | unix.BRKINT | unix.PARMRK | unix.ISTRIP | unix.INLCR | unix.IGNCR | unix.ICRNL | unix.IXON + raw.Oflag &^= unix.OPOST + raw.Lflag &^= unix.ECHO | unix.ECHONL | unix.ICANON | unix.ISIG | unix.IEXTEN + raw.Cflag &^= unix.CSIZE | unix.PARENB + raw.Cflag |= unix.CS8 + raw.Cc[unix.VMIN] = 1 + raw.Cc[unix.VTIME] = 0 + if err := unix.IoctlSetTermios(int(f.Fd()), setTermiosFlush, &raw); err != nil { + return nil, err + } + return state, nil +} + +func restoreTerminal(f *os.File, state *term.State) error { + return unix.IoctlSetTermios(int(f.Fd()), setTermiosFlush, &state.Termios) +} + +var errBeyondSelect = errors.New("cannot watch the terminal: a descriptor is beyond select(2)'s range") + +type terminalInput struct { + ctx context.Context + f *os.File + fd int32 + wake int32 // the pipe's read end: readable once ctx is done + stop func() // ends the goroutine behind the pipe and closes it + // selects is set once poll(2) reported POLLNVAL for the terminal, as it + // does for /dev/tty on macOS; select(2) watches it from then on. + selects bool +} + +func newTerminalInput(ctx context.Context, f *os.File) (*terminalInput, error) { + r, w, err := os.Pipe() + if err != nil { + return nil, err + } + done, exited := make(chan struct{}), make(chan struct{}) + go func() { + defer close(exited) + select { + case <-ctx.Done(): + _, _ = w.Write([]byte{0}) + case <-done: + } + }() + stop := sync.OnceFunc(func() { + close(done) + <-exited + _ = w.Close() + _ = r.Close() + }) + return &terminalInput{ctx: ctx, f: f, fd: int32(f.Fd()), wake: int32(r.Fd()), stop: stop}, nil +} + +func (t *terminalInput) close() { t.stop() } + +func (t *terminalInput) Read(p []byte) (int, error) { return t.f.Read(p) } + +func (t *terminalInput) wait(d time.Duration) (bool, error) { + var deadline time.Time + if d > 0 { + deadline = time.Now().Add(d) + } + for { + left := time.Duration(-1) // no limit + if d > 0 { + if left = time.Until(deadline); left <= 0 { + return false, nil + } + } + ready, err := t.watch(left) + if errors.Is(err, unix.EINTR) { // a signal: neither call is restarted + continue + } + return ready, err + } +} + +// watch waits for input or the wake-up for left, without limit when left < 0. +func (t *terminalInput) watch(left time.Duration) (bool, error) { + if t.selects { + return t.selectWatch(left) + } + timeout := -1 // no limit + if left >= 0 { + timeout = int((left + time.Millisecond - 1) / time.Millisecond) + } + fds := []unix.PollFd{{Fd: t.fd, Events: unix.POLLIN}, {Fd: t.wake, Events: unix.POLLIN}} + n, err := unix.Poll(fds, timeout) + switch { + case err != nil: + return false, err + case n == 0: + return false, nil + case fds[1].Revents != 0: // written once ctx is done + return false, t.ctx.Err() + case fds[0].Revents&unix.POLLNVAL != 0: + // poll(2) cannot watch the terminal, as macOS says of /dev/tty, and + // returned at once. select(2) can, and gets the time left. + t.selects = true + return t.selectWatch(left) + } + return true, nil // input, or a hangup or error for the read to report +} + +// selectWatch is watch by select(2), for a terminal poll(2) cannot watch. +func (t *terminalInput) selectWatch(left time.Duration) (bool, error) { + if t.fd >= unix.FD_SETSIZE || t.wake >= unix.FD_SETSIZE { + return false, errBeyondSelect + } + var timeout *unix.Timeval // no limit + if left >= 0 { + tv := unix.NsecToTimeval(int64(left)) + timeout = &tv + } + var set unix.FdSet + set.Set(int(t.fd)) + set.Set(int(t.wake)) + n, err := unix.Select(int(max(t.fd, t.wake))+1, &set, nil, nil, timeout) + switch { + case err != nil: + return false, err + case n == 0: + return false, nil + case set.IsSet(int(t.wake)): // written once ctx is done + return false, t.ctx.Err() + } + return true, nil // input, or a hangup or error for the read to report +} diff --git a/plugins/pass/commands/prompt_unix_test.go b/plugins/pass/commands/prompt_unix_test.go new file mode 100644 index 00000000..25ccc3f1 --- /dev/null +++ b/plugins/pass/commands/prompt_unix_test.go @@ -0,0 +1,174 @@ +// Copyright 2026 Docker, Inc. +// +// 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. + +//go:build !windows + +package commands + +import ( + "bytes" + "context" + "io" + "os" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_terminalInput(t *testing.T) { + t.Parallel() + // select(2) stands in for poll(2) where that rejects the terminal, as + // macOS does for /dev/tty: both calls get the same tests. + for _, selects := range []bool{false, true} { + name := "poll(2)" + if selects { + name = "select(2)" + } + t.Run(name, func(t *testing.T) { + t.Parallel() + testTerminalInput(t, selects) + }) + } + t.Run("the controlling terminal", func(t *testing.T) { + t.Parallel() + tty, err := os.Open("/dev/tty") + if err != nil { + t.Skipf("no controlling terminal: %v", err) + } + t.Cleanup(func() { _ = tty.Close() }) + in, err := newTerminalInput(t.Context(), tty) + require.NoError(t, err) + t.Cleanup(in.close) + start := time.Now() + ready, err := in.wait(escTimeout) + require.NoError(t, err) + // poll(2) rejects /dev/tty on macOS, and select(2) takes over. + assert.Equal(t, runtime.GOOS == "darwin", in.selects) + if !ready { // unless a line typed ahead awaits the shell + assert.GreaterOrEqual(t, time.Since(start), escTimeout) + } + }) +} + +func testTerminalInput(t *testing.T, selects bool) { + // pipe stands in for the tty: pollable, and read by whoever reads next. + pipe := func(t *testing.T) (r, w *os.File) { + t.Helper() + r, w, err := os.Pipe() + require.NoError(t, err) + t.Cleanup(func() { + _ = r.Close() + _ = w.Close() + }) + return r, w + } + // input watches r by the call under test. + input := func(t *testing.T, ctx context.Context, r *os.File) *terminalInput { + t.Helper() + in, err := newTerminalInput(ctx, r) + require.NoError(t, err) + t.Cleanup(in.close) + in.selects = selects + return in + } + // readBack is what r holds for its next reader: the shell, once the + // prompt has returned. + readBack := func(t *testing.T, r *os.File) string { + t.Helper() + got := make(chan string, 1) + go func() { + buf := make([]byte, 64) + n, _ := r.Read(buf) + got <- string(buf[:n]) + }() + select { + case s := <-got: + return s + case <-time.After(5 * time.Second): + t.Fatal("the line typed after the prompt went to a read it left behind") + return "" + } + } + t.Run("a wait that runs out leaves the input to its next reader", func(t *testing.T) { + t.Parallel() + r, w := pipe(t) + in := input(t, t.Context(), r) + start := time.Now() + ready, err := in.wait(escTimeout) + require.NoError(t, err) + assert.False(t, ready) + assert.GreaterOrEqual(t, time.Since(start), escTimeout) + _, err = w.WriteString("ls\n") + require.NoError(t, err) + assert.Equal(t, "ls\n", readBack(t, r)) + }) + t.Run("input ends the wait and is read", func(t *testing.T) { + t.Parallel() + r, w := pipe(t) + in := input(t, t.Context(), r) + go func() { + time.Sleep(20 * time.Millisecond) + _, _ = w.WriteString("a") + }() + ready, err := in.wait(0) + require.NoError(t, err) + assert.True(t, ready) + buf := make([]byte, 4) + n, err := in.Read(buf) + require.NoError(t, err) + assert.Equal(t, "a", string(buf[:n])) + }) + t.Run("the input's end ends the wait", func(t *testing.T) { + t.Parallel() + r, w := pipe(t) + in := input(t, t.Context(), r) + require.NoError(t, w.Close()) + ready, err := in.wait(0) + require.NoError(t, err) + assert.True(t, ready) + _, err = in.Read(make([]byte, 4)) + assert.ErrorIs(t, err, io.EOF) + }) + t.Run("cancelling the context ends the wait", func(t *testing.T) { + t.Parallel() + r, _ := pipe(t) + ctx, cancel := context.WithCancel(t.Context()) + in := input(t, ctx, r) + go func() { + time.Sleep(20 * time.Millisecond) + cancel() + }() + ready, err := in.wait(0) + assert.ErrorIs(t, err, context.Canceled) + assert.False(t, ready) + }) + t.Run("the prompt leaves nothing reading the terminal", func(t *testing.T) { + t.Parallel() + r, w := pipe(t) + in := input(t, t.Context(), r) + _, err := w.WriteString("hunter2\r") + require.NoError(t, err) + var echo bytes.Buffer + val, err := readSecretLine(in, &echo) + require.NoError(t, err) + assert.Equal(t, "hunter2", val) + // The shell's line, typed while the store is still busy. + _, err = w.WriteString("ls\n") + require.NoError(t, err) + assert.Equal(t, "ls\n", readBack(t, r)) + }) +} diff --git a/plugins/pass/commands/prompt_windows.go b/plugins/pass/commands/prompt_windows.go new file mode 100644 index 00000000..9d0ba8be --- /dev/null +++ b/plugins/pass/commands/prompt_windows.go @@ -0,0 +1,208 @@ +// Copyright 2026 Docker, Inc. +// +// 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 commands + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "sync" + "time" + "unicode/utf16" + "unicode/utf8" + "unsafe" + + "github.com/charmbracelet/x/term" + "golang.org/x/sys/windows" +) + +const ctrlZ = 0x1a // EOF on a console, as the console reader in os takes it + +var procReadConsoleInputW = windows.NewLazySystemDLL("kernel32.dll").NewProc("ReadConsoleInputW") + +func enterRaw(f *os.File) (*term.State, error) { + if err := windows.FlushConsoleInputBuffer(windows.Handle(f.Fd())); err != nil { + return nil, err + } + return term.MakeRaw(f.Fd()) +} + +func restoreTerminal(f *os.File, state *term.State) error { + err := term.Restore(f.Fd(), state) + if ferr := windows.FlushConsoleInputBuffer(windows.Handle(f.Fd())); err == nil { + err = ferr + } + return err +} + +type inputRecord struct { + eventType uint16 + _ uint16 + keyDown int32 + repeatCount uint16 + virtualKeyCode uint16 + virtualScanCode uint16 + unicodeChar uint16 + controlKeyState uint32 +} + +func (rec *inputRecord) char() rune { + if rec.eventType != windows.KEY_EVENT || (rec.keyDown == 0 && rec.virtualKeyCode != windows.VK_MENU) { + return 0 + } + return rune(rec.unicodeChar) +} + +type terminalInput struct { + ctx context.Context + h windows.Handle // console input + cancel windows.Handle // event: set once ctx is done + stop func() + buf []byte // read, not yet returned + hi rune // high surrogate awaiting its pair +} + +func newTerminalInput(ctx context.Context, f *os.File) (*terminalInput, error) { + ev, err := windows.CreateEvent(nil, 1, 0, nil) + if err != nil { + return nil, err + } + done, exited := make(chan struct{}), make(chan struct{}) + go func() { + defer close(exited) + select { + case <-ctx.Done(): + _ = windows.SetEvent(ev) + case <-done: + } + }() + t := &terminalInput{ctx: ctx, h: windows.Handle(f.Fd()), cancel: ev, buf: make([]byte, 0, 4096)} + t.stop = sync.OnceFunc(func() { + close(done) + <-exited + _ = windows.CloseHandle(ev) + clear(t.buf[:cap(t.buf)]) + }) + return t, nil +} + +func (t *terminalInput) close() { t.stop() } + +func (t *terminalInput) wait(d time.Duration) (bool, error) { + var deadline time.Time + if d > 0 { + deadline = time.Now().Add(d) + } + for len(t.buf) == 0 { + timeout := uint32(windows.INFINITE) + if d > 0 { + left := time.Until(deadline) + if left <= 0 { + return false, nil + } + timeout = uint32((left + time.Millisecond - 1) / time.Millisecond) + } + ev, err := windows.WaitForMultipleObjects([]windows.Handle{t.h, t.cancel}, false, timeout) + switch ev { + case windows.WAIT_OBJECT_0: // records queued + if err := t.readRecords(); err != nil { + return false, err + } + case windows.WAIT_OBJECT_0 + 1: + return false, t.ctx.Err() + case uint32(windows.WAIT_TIMEOUT): + return false, nil + default: + if err == nil { + err = fmt.Errorf("waiting for console input: %#x", ev) + } + return false, err + } + } + return true, nil +} + +// readRecords takes the records queued and keeps the characters among them, +// each as often as its repeat count says. +func (t *terminalInput) readRecords() error { + var recs [64]inputRecord + var n uint32 + r1, _, e1 := procReadConsoleInputW.Call(uintptr(t.h), uintptr(unsafe.Pointer(&recs[0])), uintptr(len(recs)), uintptr(unsafe.Pointer(&n))) + if r1 == 0 { + return e1 + } + defer clear(recs[:n]) + for i := range recs[:n] { + r := recs[i].char() + if r == 0 { + continue + } + for range max(recs[i].repeatCount, 1) { + t.push(r) + } + } + return nil +} + +func (t *terminalInput) push(r rune) { + if hi := t.hi; hi != 0 { + t.hi = 0 + if pair := utf16.DecodeRune(hi, r); pair != utf8.RuneError { + t.buf = utf8.AppendRune(t.buf, pair) + return + } + t.buf = appendHalf(t.buf, hi) // the half on its own + } + switch { + case !utf16.IsSurrogate(r): + t.buf = utf8.AppendRune(t.buf, r) + case r < 0xdc00: // the high half: its pair follows + t.hi = r + default: + t.buf = appendHalf(t.buf, r) // a low half on its own + } +} + +func appendHalf(p []byte, r rune) []byte { + return append(p, 0xe0|byte(r>>12), 0x80|(byte(r>>6)&0x3f), 0x80|(byte(r)&0x3f)) +} + +func (t *terminalInput) Read(p []byte) (int, error) { + for len(t.buf) == 0 { + if _, err := t.wait(0); err != nil { + return 0, err + } + } + n := len(t.buf) + if i := bytes.IndexByte(t.buf, ctrlZ); i >= 0 { + if i == 0 { + t.drop(1) + return 0, io.EOF + } + n = i + } + n = copy(p, t.buf[:n]) + t.drop(n) + return n, nil +} + +// drop discards the first n bytes read, zeroing them. +func (t *terminalInput) drop(n int) { + rest := copy(t.buf, t.buf[n:]) + clear(t.buf[rest:]) + t.buf = t.buf[:rest] +} diff --git a/plugins/pass/commands/prompt_windows_test.go b/plugins/pass/commands/prompt_windows_test.go new file mode 100644 index 00000000..6fa83ddb --- /dev/null +++ b/plugins/pass/commands/prompt_windows_test.go @@ -0,0 +1,115 @@ +// Copyright 2026 Docker, Inc. +// +// 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 commands + +import ( + "bytes" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +func Test_inputRecord_char(t *testing.T) { + t.Parallel() + key := func(down bool, vk, ch uint16) inputRecord { + rec := inputRecord{eventType: windows.KEY_EVENT, repeatCount: 1, virtualKeyCode: vk, unicodeChar: ch} + if down { + rec.keyDown = 1 + } + return rec + } + for name, tc := range map[string]struct { + rec inputRecord + want rune + }{ + "key press": {key(true, 'A', 'a'), 'a'}, + "key release": {key(false, 'A', 'a'), 0}, + "press of a key without a char": {key(true, 0x10 /* VK_SHIFT */, 0), 0}, + "alt release after alt+numpad": {key(false, windows.VK_MENU, 'é'), 'é'}, + "alt release alone": {key(false, windows.VK_MENU, 0), 0}, + "mouse event": {inputRecord{eventType: 0x0002 /* MOUSE_EVENT */, keyDown: 1, unicodeChar: 'a'}, 0}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, tc.rec.char()) + }) + } +} + +func Test_terminalInput_push(t *testing.T) { + t.Parallel() + for name, tc := range map[string]struct { + in []rune + want string + }{ + "text": {[]rune("ab"), "ab"}, + "surrogate pair": {[]rune{0xd83d, 0xde00}, "😀"}, + "replacement character": {[]rune{'�'}, "�"}, + "high half then text": {[]rune{0xd83d, 'a'}, "\xed\xa0\xbda"}, + "high half then high half": {[]rune{0xd83d, 0xd83d, 0xde00}, "\xed\xa0\xbd😀"}, + "low half alone": {[]rune{0xde00}, "\xed\xb8\x80"}, + "high half at the end stays": {[]rune{'a', 0xd83d}, "a"}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + in := &terminalInput{} + for _, r := range tc.in { + in.push(r) + } + assert.Equal(t, tc.want, string(in.buf)) + }) + } + t.Run("a half on its own is invalid UTF-8 to the prompt", func(t *testing.T) { + t.Parallel() + in := &terminalInput{} + for _, r := range []rune{'a', 0xd83d, 'b', '\r'} { + in.push(r) + } + var echo bytes.Buffer + val, err := readSecretLine(in, &echo) + assert.ErrorIs(t, err, errInvalidUTF8) + assert.Empty(t, val) + assert.Equal(t, "*", echo.String()) + }) +} + +func Test_terminalInput_Read(t *testing.T) { + t.Parallel() + t.Run("ctrl+z is EOF, once, as in os", func(t *testing.T) { + t.Parallel() + in := &terminalInput{buf: []byte("ab\x1acd")} + buf := make([]byte, 8) + n, err := in.Read(buf) + require.NoError(t, err) + assert.Equal(t, "ab", string(buf[:n])) + _, err = in.Read(buf) + assert.ErrorIs(t, err, io.EOF) + n, err = in.Read(buf) + require.NoError(t, err) + assert.Equal(t, "cd", string(buf[:n])) + }) + t.Run("what was read is zeroed", func(t *testing.T) { + t.Parallel() + in := &terminalInput{buf: []byte("hunter2")} + buf := make([]byte, 3) + n, err := in.Read(buf) + require.NoError(t, err) + assert.Equal(t, "hun", string(buf[:n])) + assert.Equal(t, "ter2\x00\x00\x00", string(in.buf[:cap(in.buf)])) + }) +} diff --git a/plugins/pass/commands/set.go b/plugins/pass/commands/set.go index 7b161e17..294552d4 100644 --- a/plugins/pass/commands/set.go +++ b/plugins/pass/commands/set.go @@ -23,6 +23,7 @@ import ( "maps" "strings" + "github.com/charmbracelet/x/term" "github.com/spf13/cobra" pass "github.com/docker/secrets-engine/plugins/pass/store" @@ -37,7 +38,7 @@ var setLong string type setOpts struct { metadata []string // raw "key=value" strings from --metadata flag - force bool // if true, overwrite existing secret instead of erroring + force bool // if true, overwrite existing setPayload instead of erroring } type stdinPayload struct { @@ -59,26 +60,25 @@ func SetCommand() *cobra.Command { if err != nil { return err } - var s secret - if isNotImplicitReadFromStdinSyntax(args) { - va, err := parseArg(args[0]) - if err != nil { - return err - } - s = *va - } else { - val, err := secretMappingFromSTDIN(cmd.Context(), cmd.InOrStdin(), args[0]) - if err != nil { - return err - } - s = *val + key, value, _ := strings.Cut(args[0], "=") + id, err := secrets.ParseID(key) + if err != nil { + return err } - id, err := secrets.ParseID(s.id) + flagMeta, err := parseMetadataFlags(opts.metadata) if err != nil { return err } - flagMeta, err := parseMetadataFlags(opts.metadata) + var s *setPayload + switch in, isFile := unwrapFile(cmd.InOrStdin()); { + case isNotImplicitReadFromStdinSyntax(args): + s = &setPayload{val: value} + case isFile && term.IsTerminal(in.Fd()): + s, err = secretFromPrompt(cmd.Context(), in, cmd.ErrOrStderr(), id) + default: + s, err = secretMappingFromSTDIN(cmd.Context(), cmd.InOrStdin()) + } if err != nil { return err } @@ -129,7 +129,7 @@ func isNotImplicitReadFromStdinSyntax(args []string) bool { return strings.Contains(args[0], "=") || len(args) > 1 } -func secretMappingFromSTDIN(ctx context.Context, reader io.Reader, id string) (*secret, error) { +func secretMappingFromSTDIN(ctx context.Context, reader io.Reader) (*setPayload, error) { data, err := readAllWithContext(ctx, reader) if err != nil { return nil, err @@ -138,25 +138,16 @@ func secretMappingFromSTDIN(ctx context.Context, reader io.Reader, id string) (* var payload stdinPayload if err := json.Unmarshal(data, &payload); err == nil && payload.Secret != "" { - return &secret{id: id, val: payload.Secret, metadata: payload.Metadata}, nil + return &setPayload{val: payload.Secret, metadata: payload.Metadata}, nil } - return &secret{id: id, val: string(data)}, nil + return &setPayload{val: string(data)}, nil } -type secret struct { - id string +type setPayload struct { val string metadata map[string]string } -func parseArg(arg string) (*secret, error) { - key, value, found := strings.Cut(arg, "=") - if !found { - return nil, fmt.Errorf("no key=value pair: %s", arg) - } - return &secret{id: key, val: value}, nil -} - func readAllWithContext(ctx context.Context, r io.Reader) ([]byte, error) { var buf []byte done := make(chan error, 1) diff --git a/plugins/pass/commands/set_example.md b/plugins/pass/commands/set_example.md index 908a4244..3f4be4ac 100644 --- a/plugins/pass/commands/set_example.md +++ b/plugins/pass/commands/set_example.md @@ -1,22 +1,36 @@ -### Set a secret: +### Type or paste the value at the prompt: ```console -$ docker pass set POSTGRES_PASSWORD=my-secret-password +$ docker pass set POSTGRES_PASSWORD +Enter secret for POSTGRES_PASSWORD: ****************** ``` -### Or pass the secret via STDIN: +### Pipe the value from another command: ```console $ printf '%s' my-secret-password | docker pass set POSTGRES_PASSWORD ``` -### Set a secret with metadata: +### Read a multi-line value from a file: + +```console +$ docker pass set my-cert < cert.pem +``` + +### Pass the value inline: + +```console +$ docker pass set POSTGRES_PASSWORD=my-secret-password +``` + +### Attach metadata: ```console -$ docker pass set POSTGRES_PASSWORD=my-secret-password --metadata owner=alice --metadata expiry=2027-03-01 +$ docker pass set POSTGRES_PASSWORD --metadata owner=alice --metadata expiry=2027-03-01 +Enter secret for POSTGRES_PASSWORD: ****************** ``` -### Or pass a JSON payload with secret and metadata via STDIN: +### Pipe value and metadata as JSON: ```console $ echo '{"secret":"my-secret-password","metadata":{"owner":"alice"}}' | docker pass set POSTGRES_PASSWORD @@ -25,5 +39,6 @@ $ echo '{"secret":"my-secret-password","metadata":{"owner":"alice"}}' | docker p ### Overwrite an existing secret: ```console -$ docker pass set POSTGRES_PASSWORD=new-secret-password --force +$ docker pass set POSTGRES_PASSWORD --force +Enter secret for POSTGRES_PASSWORD: ****************** ``` diff --git a/plugins/pass/commands/set_long.md b/plugins/pass/commands/set_long.md index 7cc4206b..aa39aac9 100644 --- a/plugins/pass/commands/set_long.md +++ b/plugins/pass/commands/set_long.md @@ -1,10 +1,13 @@ -Stores a secret in the local OS keychain. The secret value can be provided inline (`NAME=VALUE`) or piped via STDIN. +Stores a secret in the local OS keychain. Three ways to pass the value: + - `docker pass set NAME` prompts for it when standard input is a terminal. + Typed or pasted input is masked. + - `... | docker pass set NAME` or `docker pass set NAME < file` reads it + from standard input. Use this for scripts and for multi-line or binary + values. + - `docker pass set NAME=VALUE` sets it inline. Avoid this in an interactive + shell as the value ends up in your shell history. -Behavior when a secret with the same id already exists is platform-dependent: - - macOS (Keychain): the command fails with a duplicate-item error. - - Linux (Secret Service) and Windows (Credential Manager): the existing - value is silently overwritten. - -Pass `--force` to overwrite an existing secret. On Linux and Windows the -replacement is performed atomically. On macOS the Keychain API requires -a delete-then-add sequence. +Pass `--force` to overwrite an existing secret. Without it, macOS (Keychain) +refuses with a duplicate-item error, while Linux (Secret Service) and Windows +(Credential Manager) overwrite silently. The replacement is atomic except on +macOS, where the Keychain API deletes the old item before adding the new one. diff --git a/plugins/pass/go.mod b/plugins/pass/go.mod index 9411e5ee..db109915 100644 --- a/plugins/pass/go.mod +++ b/plugins/pass/go.mod @@ -9,6 +9,7 @@ replace github.com/docker/secrets-engine/store => ./../../store replace github.com/docker/secrets-engine/x => ./../../x require ( + github.com/charmbracelet/x/term v0.2.2 github.com/docker/secrets-engine/client v0.1.2 github.com/docker/secrets-engine/plugin v0.3.2 github.com/docker/secrets-engine/store v0.4.1 @@ -16,6 +17,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 + golang.org/x/sys v0.46.0 ) require ( @@ -29,7 +31,6 @@ require ( github.com/spf13/pflag v1.0.9 // indirect golang.org/x/crypto v0.52.0 // indirect golang.org/x/mod v0.36.0 // indirect - golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/plugins/pass/go.sum b/plugins/pass/go.sum index 99303b73..105cee25 100644 --- a/plugins/pass/go.sum +++ b/plugins/pass/go.sum @@ -1,5 +1,7 @@ connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/containerd/nri v0.11.0 h1:26mcQwNG58AZn0YkOrlJQ0yxQVmyZooflnVWJTqQrqQ= github.com/containerd/nri v0.11.0/go.mod h1:bjGTLdUA58WgghKHg8azFMGXr05n1wDHrt3NSVBHiGI= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -42,8 +44,8 @@ golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/store/go.mod b/store/go.mod index e26941df..af7dbea1 100644 --- a/store/go.mod +++ b/store/go.mod @@ -17,7 +17,7 @@ require ( github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.52.0 - golang.org/x/sys v0.45.0 + golang.org/x/sys v0.46.0 golang.org/x/text v0.37.0 ) diff --git a/store/go.sum b/store/go.sum index 105e4480..b3a33430 100644 --- a/store/go.sum +++ b/store/go.sum @@ -39,8 +39,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= diff --git a/vendor/github.com/charmbracelet/x/term/LICENSE b/vendor/github.com/charmbracelet/x/term/LICENSE new file mode 100644 index 00000000..65a5654e --- /dev/null +++ b/vendor/github.com/charmbracelet/x/term/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Charmbracelet, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/charmbracelet/x/term/term.go b/vendor/github.com/charmbracelet/x/term/term.go new file mode 100644 index 00000000..151e4758 --- /dev/null +++ b/vendor/github.com/charmbracelet/x/term/term.go @@ -0,0 +1,51 @@ +// Package term provides a platform-independent interfaces for interacting with +// Terminal and TTY devices. +package term + +// State contains platform-specific state of a terminal. +type State struct { + state +} + +// IsTerminal returns whether the given file descriptor is a terminal. +func IsTerminal(fd uintptr) bool { + return isTerminal(fd) +} + +// MakeRaw puts the terminal connected to the given file descriptor into raw +// mode and returns the previous state of the terminal so that it can be +// restored. +func MakeRaw(fd uintptr) (*State, error) { + return makeRaw(fd) +} + +// GetState returns the current state of a terminal which may be useful to +// restore the terminal after a signal. +func GetState(fd uintptr) (*State, error) { + return getState(fd) +} + +// SetState sets the given state of the terminal. +func SetState(fd uintptr, state *State) error { + return setState(fd, state) +} + +// Restore restores the terminal connected to the given file descriptor to a +// previous state. +func Restore(fd uintptr, oldState *State) error { + return restore(fd, oldState) +} + +// GetSize returns the visible dimensions of the given terminal. +// +// These dimensions don't include any scrollback buffer height. +func GetSize(fd uintptr) (width, height int, err error) { + return getSize(fd) +} + +// ReadPassword reads a line of input from a terminal without local echo. This +// is commonly used for inputting passwords and other sensitive data. The slice +// returned does not include the \n. +func ReadPassword(fd uintptr) ([]byte, error) { + return readPassword(fd) +} diff --git a/vendor/github.com/charmbracelet/x/term/term_other.go b/vendor/github.com/charmbracelet/x/term/term_other.go new file mode 100644 index 00000000..092c7e9d --- /dev/null +++ b/vendor/github.com/charmbracelet/x/term/term_other.go @@ -0,0 +1,39 @@ +//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !zos && !windows && !solaris && !plan9 +// +build !aix,!darwin,!dragonfly,!freebsd,!linux,!netbsd,!openbsd,!zos,!windows,!solaris,!plan9 + +package term + +import ( + "fmt" + "runtime" +) + +type state struct{} + +func isTerminal(fd uintptr) bool { + return false +} + +func makeRaw(fd uintptr) (*State, error) { + return nil, fmt.Errorf("terminal: MakeRaw not implemented on %s/%s", runtime.GOOS, runtime.GOARCH) +} + +func getState(fd uintptr) (*State, error) { + return nil, fmt.Errorf("terminal: GetState not implemented on %s/%s", runtime.GOOS, runtime.GOARCH) +} + +func restore(fd uintptr, state *State) error { + return fmt.Errorf("terminal: Restore not implemented on %s/%s", runtime.GOOS, runtime.GOARCH) +} + +func getSize(fd uintptr) (width, height int, err error) { + return 0, 0, fmt.Errorf("terminal: GetSize not implemented on %s/%s", runtime.GOOS, runtime.GOARCH) +} + +func setState(fd uintptr, state *State) error { + return fmt.Errorf("terminal: SetState not implemented on %s/%s", runtime.GOOS, runtime.GOARCH) +} + +func readPassword(fd uintptr) ([]byte, error) { + return nil, fmt.Errorf("terminal: ReadPassword not implemented on %s/%s", runtime.GOOS, runtime.GOARCH) +} diff --git a/vendor/github.com/charmbracelet/x/term/term_plan9.go b/vendor/github.com/charmbracelet/x/term/term_plan9.go new file mode 100644 index 00000000..eba3bdf7 --- /dev/null +++ b/vendor/github.com/charmbracelet/x/term/term_plan9.go @@ -0,0 +1,118 @@ +package term + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +type state struct { + termName string + raw bool + ctl *os.File +} + +// termName returns the name of the terminal or os.ErrNotExist if there is no terminal. +func termName(fd uintptr) (string, error) { + ctl, err := os.ReadFile(filepath.Join("/fd", fmt.Sprintf("%dctl", fd))) + if err != nil { + return "", err + } + f := strings.Fields(string(ctl)) + if len(f) == 0 { + return "", os.ErrNotExist + } + return f[len(f)-1], nil +} + +func isTerminal(fd uintptr) bool { + ctl, err := os.ReadFile(filepath.Join("/fd", fmt.Sprintf("%dctl", fd))) + if err != nil { + return false + } + if strings.Contains(string(ctl), "/dev/cons") { + return true + } + return false +} + +func makeRaw(fd uintptr) (*State, error) { + t, err := termName(fd) + if err != nil { + return nil, err + } + ctl, err := os.OpenFile(t, os.O_RDWR, 0) + if err != nil { + return nil, err + } + if _, err := ctl.Write([]byte("rawon")); err != nil { + return nil, err + } + return &State{state: state{termName: t, raw: true, ctl: ctl}}, nil +} + +func getState(fd uintptr) (*State, error) { + t, err := termName(fd) + if err != nil { + return nil, err + } + ctl, err := os.OpenFile(t, os.O_RDWR, 0) + if err != nil { + return nil, err + } + return &State{state: state{termName: t, raw: false, ctl: ctl}}, nil + +} + +func restore(_ uintptr, state *State) error { + if _, err := state.ctl.Write([]byte("rawoff")); err != nil { + return err + } + return nil +} + +// getSize returns the size. This will only work if you are running +// under a window manager in Plan 9. Else, the only option +// is to return a reasonable default. +func getSize(fd uintptr) (int, int, error) { + w, h := 80, 40 + b, err := os.ReadFile("/dev/wctl") + if err != nil { + return w, h, err + } + f := strings.Fields(string(b)) + if len(f) != 4 { + return w, h, fmt.Errorf("%q only has %d of 4 needed fields:%w", f, len(f), os.ErrInvalid) + } + // The contents of wctl, as defined in the driver, are + // 4 12-char fields: upper left x, y; and lower-right x, y + var ulx, uly, lrx, lry int + if n, err := fmt.Sscanf(string(b[:48]), "%d%d%d%d", &ulx, &uly, &lrx, &lry); n != 4 || err != nil { + return w, h, fmt.Errorf("scanning %q:%d of 4 items scanned:%w", string(b[:48]), n, err) + } + + w, h = lrx-lrx, lry-uly + return w, h, nil +} + +func setState(_ uintptr, state *State) error { + raw := "rawoff" + if state.raw { + raw = "rawon" + } + if _, err := state.ctl.Write([]byte(raw)); err != nil { + return err + } + return nil +} + +func readPassword(fd uintptr) ([]byte, error) { + f := os.NewFile(fd, "cons") + var b [128]byte + n, err := f.Read(b[:]) + if err != nil { + return nil, err + } + return b[:n], nil +} diff --git a/vendor/github.com/charmbracelet/x/term/term_unix.go b/vendor/github.com/charmbracelet/x/term/term_unix.go new file mode 100644 index 00000000..77706d48 --- /dev/null +++ b/vendor/github.com/charmbracelet/x/term/term_unix.go @@ -0,0 +1,96 @@ +//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || zos +// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris zos + +package term + +import ( + "golang.org/x/sys/unix" +) + +type state struct { + unix.Termios +} + +func isTerminal(fd uintptr) bool { + _, err := unix.IoctlGetTermios(int(fd), ioctlReadTermios) + return err == nil +} + +func makeRaw(fd uintptr) (*State, error) { + termios, err := unix.IoctlGetTermios(int(fd), ioctlReadTermios) + if err != nil { + return nil, err //nolint:wrapcheck + } + + oldState := State{state{Termios: *termios}} + + // This attempts to replicate the behaviour documented for cfmakeraw in + // the termios(3) manpage. + termios.Iflag &^= unix.IGNBRK | unix.BRKINT | unix.PARMRK | unix.ISTRIP | unix.INLCR | unix.IGNCR | unix.ICRNL | unix.IXON + termios.Oflag &^= unix.OPOST + termios.Lflag &^= unix.ECHO | unix.ECHONL | unix.ICANON | unix.ISIG | unix.IEXTEN + termios.Cflag &^= unix.CSIZE | unix.PARENB + termios.Cflag |= unix.CS8 + termios.Cc[unix.VMIN] = 1 + termios.Cc[unix.VTIME] = 0 + if err := unix.IoctlSetTermios(int(fd), ioctlWriteTermios, termios); err != nil { + return nil, err //nolint:wrapcheck + } + + return &oldState, nil +} + +func setState(fd uintptr, state *State) error { + var termios *unix.Termios + if state != nil { + termios = &state.Termios + } + return unix.IoctlSetTermios(int(fd), ioctlWriteTermios, termios) //nolint:wrapcheck +} + +func getState(fd uintptr) (*State, error) { + termios, err := unix.IoctlGetTermios(int(fd), ioctlReadTermios) + if err != nil { + return nil, err //nolint:wrapcheck + } + + return &State{state{Termios: *termios}}, nil +} + +func restore(fd uintptr, state *State) error { + return unix.IoctlSetTermios(int(fd), ioctlWriteTermios, &state.Termios) //nolint:wrapcheck +} + +func getSize(fd uintptr) (width, height int, err error) { + ws, err := unix.IoctlGetWinsize(int(fd), unix.TIOCGWINSZ) + if err != nil { + return 0, 0, err //nolint:wrapcheck + } + return int(ws.Col), int(ws.Row), nil +} + +// passwordReader is an io.Reader that reads from a specific file descriptor. +type passwordReader int + +func (r passwordReader) Read(buf []byte) (int, error) { + return unix.Read(int(r), buf) //nolint:wrapcheck +} + +func readPassword(fd uintptr) ([]byte, error) { + termios, err := unix.IoctlGetTermios(int(fd), ioctlReadTermios) + if err != nil { + return nil, err //nolint:wrapcheck + } + + newState := *termios + newState.Lflag &^= unix.ECHO + newState.Lflag |= unix.ICANON | unix.ISIG + newState.Iflag |= unix.ICRNL + if err := unix.IoctlSetTermios(int(fd), ioctlWriteTermios, &newState); err != nil { + return nil, err //nolint:wrapcheck + } + + defer unix.IoctlSetTermios(int(fd), ioctlWriteTermios, termios) //nolint:errcheck + + return readPasswordLine(passwordReader(fd)) +} diff --git a/vendor/github.com/charmbracelet/x/term/term_unix_bsd.go b/vendor/github.com/charmbracelet/x/term/term_unix_bsd.go new file mode 100644 index 00000000..b435031a --- /dev/null +++ b/vendor/github.com/charmbracelet/x/term/term_unix_bsd.go @@ -0,0 +1,11 @@ +//go:build darwin || dragonfly || freebsd || netbsd || openbsd +// +build darwin dragonfly freebsd netbsd openbsd + +package term + +import "golang.org/x/sys/unix" + +const ( + ioctlReadTermios = unix.TIOCGETA + ioctlWriteTermios = unix.TIOCSETA +) diff --git a/vendor/github.com/charmbracelet/x/term/term_unix_other.go b/vendor/github.com/charmbracelet/x/term/term_unix_other.go new file mode 100644 index 00000000..ee2a29eb --- /dev/null +++ b/vendor/github.com/charmbracelet/x/term/term_unix_other.go @@ -0,0 +1,11 @@ +//go:build aix || linux || solaris || zos +// +build aix linux solaris zos + +package term + +import "golang.org/x/sys/unix" + +const ( + ioctlReadTermios = unix.TCGETS + ioctlWriteTermios = unix.TCSETS +) diff --git a/vendor/github.com/charmbracelet/x/term/term_windows.go b/vendor/github.com/charmbracelet/x/term/term_windows.go new file mode 100644 index 00000000..d94f6845 --- /dev/null +++ b/vendor/github.com/charmbracelet/x/term/term_windows.go @@ -0,0 +1,87 @@ +//go:build windows +// +build windows + +package term + +import ( + "os" + + "golang.org/x/sys/windows" +) + +type state struct { + Mode uint32 +} + +func isTerminal(fd uintptr) bool { + var st uint32 + err := windows.GetConsoleMode(windows.Handle(fd), &st) + return err == nil +} + +func makeRaw(fd uintptr) (*State, error) { + var st uint32 + if err := windows.GetConsoleMode(windows.Handle(fd), &st); err != nil { + return nil, err + } + raw := st &^ (windows.ENABLE_ECHO_INPUT | windows.ENABLE_PROCESSED_INPUT | windows.ENABLE_LINE_INPUT) + raw |= windows.ENABLE_VIRTUAL_TERMINAL_INPUT + if err := windows.SetConsoleMode(windows.Handle(fd), raw); err != nil { + return nil, err + } + return &State{state{st}}, nil +} + +func setState(fd uintptr, state *State) error { + var mode uint32 + if state != nil { + mode = state.Mode + } + return windows.SetConsoleMode(windows.Handle(fd), mode) +} + +func getState(fd uintptr) (*State, error) { + var st uint32 + if err := windows.GetConsoleMode(windows.Handle(fd), &st); err != nil { + return nil, err + } + return &State{state{st}}, nil +} + +func restore(fd uintptr, state *State) error { + return windows.SetConsoleMode(windows.Handle(fd), state.Mode) +} + +func getSize(fd uintptr) (width, height int, err error) { + var info windows.ConsoleScreenBufferInfo + if err := windows.GetConsoleScreenBufferInfo(windows.Handle(fd), &info); err != nil { + return 0, 0, err + } + return int(info.Window.Right - info.Window.Left + 1), int(info.Window.Bottom - info.Window.Top + 1), nil +} + +func readPassword(fd uintptr) ([]byte, error) { + var st uint32 + if err := windows.GetConsoleMode(windows.Handle(fd), &st); err != nil { + return nil, err + } + old := st + + st &^= (windows.ENABLE_ECHO_INPUT | windows.ENABLE_LINE_INPUT) + st |= (windows.ENABLE_PROCESSED_OUTPUT | windows.ENABLE_PROCESSED_INPUT) + if err := windows.SetConsoleMode(windows.Handle(fd), st); err != nil { + return nil, err + } + + defer windows.SetConsoleMode(windows.Handle(fd), old) + + var h windows.Handle + p, _ := windows.GetCurrentProcess() + if err := windows.DuplicateHandle(p, windows.Handle(fd), p, &h, 0, false, windows.DUPLICATE_SAME_ACCESS); err != nil { + return nil, err + } + + f := os.NewFile(uintptr(h), "stdin") + defer f.Close() + return readPasswordLine(f) +} diff --git a/vendor/github.com/charmbracelet/x/term/terminal.go b/vendor/github.com/charmbracelet/x/term/terminal.go new file mode 100644 index 00000000..8963163f --- /dev/null +++ b/vendor/github.com/charmbracelet/x/term/terminal.go @@ -0,0 +1,12 @@ +package term + +import ( + "io" +) + +// File represents a file that has a file descriptor and can be read from, +// written to, and closed. +type File interface { + io.ReadWriteCloser + Fd() uintptr +} diff --git a/vendor/github.com/charmbracelet/x/term/util.go b/vendor/github.com/charmbracelet/x/term/util.go new file mode 100644 index 00000000..9c2b628b --- /dev/null +++ b/vendor/github.com/charmbracelet/x/term/util.go @@ -0,0 +1,47 @@ +package term + +import ( + "io" + "runtime" +) + +// readPasswordLine reads from reader until it finds \n or io.EOF. +// The slice returned does not include the \n. +// readPasswordLine also ignores any \r it finds. +// Windows uses \r as end of line. So, on Windows, readPasswordLine +// reads until it finds \r and ignores any \n it finds during processing. +func readPasswordLine(reader io.Reader) ([]byte, error) { + var buf [1]byte + var ret []byte + + for { + n, err := reader.Read(buf[:]) + if n > 0 { + switch buf[0] { + case '\b': + if len(ret) > 0 { + ret = ret[:len(ret)-1] + } + case '\n': + if runtime.GOOS != "windows" { + return ret, nil + } + // otherwise ignore \n + case '\r': + if runtime.GOOS == "windows" { + return ret, nil + } + // otherwise ignore \r + default: + ret = append(ret, buf[0]) + } + continue + } + if err != nil { + if err == io.EOF && len(ret) > 0 { + return ret, nil + } + return ret, err //nolint:wrapcheck + } + } +} diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux.go b/vendor/golang.org/x/sys/unix/ztypes_linux.go index d11d5b96..526a0d5f 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux.go @@ -6397,3 +6397,79 @@ const ( MPOL_PREFERRED_MANY = 0x5 MPOL_WEIGHTED_INTERLEAVE = 0x6 ) + +const ( + GPIO_V2_GET_LINEINFO_IOCTL = 0xc100b405 + GPIO_V2_GET_LINE_IOCTL = 0xc250b407 + GPIO_V2_LINE_GET_VALUES_IOCTL = 0xc010b40e + GPIO_V2_LINE_SET_VALUES_IOCTL = 0xc010b40f + GPIO_V2_GET_LINEINFO_WATCH_IOCTL = 0xc100b406 + GPIO_GET_LINEINFO_UNWATCH_IOCTL = 0xc004b40c +) +const ( + GPIO_V2_LINE_ATTR_ID_FLAGS = 0x1 + GPIO_V2_LINE_ATTR_ID_OUTPUT_VALUES = 0x2 + GPIO_V2_LINE_ATTR_ID_DEBOUNCE = 0x3 + GPIO_V2_LINE_CHANGED_REQUESTED = 0x1 + GPIO_V2_LINE_CHANGED_RELEASED = 0x2 + GPIO_V2_LINE_CHANGED_CONFIG = 0x3 + GPIO_V2_LINE_EVENT_RISING_EDGE = 0x1 + GPIO_V2_LINE_EVENT_FALLING_EDGE = 0x2 +) + +type GPIOChipInfo struct { + Name [32]byte + Label [32]byte + Lines uint32 +} +type GPIOV2LineValues struct { + Bits uint64 + Mask uint64 +} +type GPIOV2LineAttribute struct { + Id uint32 + _ uint32 + Flags uint64 +} +type GPIOV2LineConfigAttribute struct { + Attr GPIOV2LineAttribute + Mask uint64 +} +type GPIOV2LineConfig struct { + Flags uint64 + Num_attrs uint32 + _ [5]uint32 + Attrs [10]GPIOV2LineConfigAttribute +} +type GPIOV2LineRequest struct { + Offsets [64]uint32 + Consumer [32]byte + Config GPIOV2LineConfig + Num_lines uint32 + Event_buffer_size uint32 + _ [5]uint32 + Fd int32 +} +type GPIOV2LineInfo struct { + Name [32]byte + Consumer [32]byte + Offset uint32 + Num_attrs uint32 + Flags uint64 + Attrs [10]GPIOV2LineAttribute + _ [4]uint32 +} +type GPIOV2LineInfoChanged struct { + Info GPIOV2LineInfo + Timestamp_ns uint64 + Event_type uint32 + _ [5]uint32 +} +type GPIOV2LineEvent struct { + Timestamp_ns uint64 + Id uint32 + Offset uint32 + Seqno uint32 + Line_seqno uint32 + _ [6]uint32 +} diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_386.go b/vendor/golang.org/x/sys/unix/ztypes_linux_386.go index 97ef790d..aede1de7 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_386.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_386.go @@ -711,3 +711,7 @@ type SysvShmDesc struct { _ uint32 _ uint32 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_amd64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_amd64.go index 90b50da6..bb3bc4dc 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_amd64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_amd64.go @@ -725,3 +725,7 @@ type SysvShmDesc struct { _ uint64 _ uint64 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_arm.go b/vendor/golang.org/x/sys/unix/ztypes_linux_arm.go index acda1368..1fdf4c51 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_arm.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_arm.go @@ -705,3 +705,7 @@ type SysvShmDesc struct { _ uint32 _ uint32 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_arm64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_arm64.go index ef7a99e1..063e6f0b 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_arm64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_arm64.go @@ -704,3 +704,7 @@ type SysvShmDesc struct { _ uint64 _ uint64 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_loong64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_loong64.go index 966063df..9cf836c7 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_loong64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_loong64.go @@ -705,3 +705,7 @@ type SysvShmDesc struct { _ uint64 _ uint64 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_mips.go b/vendor/golang.org/x/sys/unix/ztypes_linux_mips.go index dc53b20b..1d222fcb 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_mips.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_mips.go @@ -710,3 +710,7 @@ type SysvShmDesc struct { Ctime_high uint16 _ uint16 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_mips64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_mips64.go index 9ad0aa8c..912cc4ab 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_mips64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_mips64.go @@ -707,3 +707,7 @@ type SysvShmDesc struct { _ uint64 _ uint64 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_mips64le.go b/vendor/golang.org/x/sys/unix/ztypes_linux_mips64le.go index 29d55493..1e358ef3 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_mips64le.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_mips64le.go @@ -707,3 +707,7 @@ type SysvShmDesc struct { _ uint64 _ uint64 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_mipsle.go b/vendor/golang.org/x/sys/unix/ztypes_linux_mipsle.go index a4d9e158..df59f32f 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_mipsle.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_mipsle.go @@ -710,3 +710,7 @@ type SysvShmDesc struct { Ctime_high uint16 _ uint16 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc.go b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc.go index f8a29777..29355aa0 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc.go @@ -718,3 +718,7 @@ type SysvShmDesc struct { _ uint32 _ [4]byte } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64.go index 4158d6c4..c6083a15 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64.go @@ -713,3 +713,7 @@ type SysvShmDesc struct { _ uint64 _ uint64 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64le.go b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64le.go index 1035af49..6321cc76 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64le.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64le.go @@ -713,3 +713,7 @@ type SysvShmDesc struct { _ uint64 _ uint64 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_riscv64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_riscv64.go index 2297125d..b44f402f 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_riscv64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_riscv64.go @@ -792,3 +792,7 @@ const ( RISCV_HWPROBE_KEY_ZICBOZ_BLOCK_SIZE = 0x6 RISCV_HWPROBE_WHICH_CPUS = 0x1 ) + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_s390x.go b/vendor/golang.org/x/sys/unix/ztypes_linux_s390x.go index 8481e9bd..b22c795a 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_s390x.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_s390x.go @@ -727,3 +727,7 @@ type SysvShmDesc struct { _ uint64 _ uint64 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_sparc64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_sparc64.go index a6828a03..0b18075b 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_sparc64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_sparc64.go @@ -708,3 +708,7 @@ type SysvShmDesc struct { _ uint64 _ uint64 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 +) diff --git a/vendor/modules.txt b/vendor/modules.txt index 3e5356ef..c0d807a2 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -23,6 +23,9 @@ filippo.io/hpke/internal/byteorder # github.com/cenkalti/backoff/v5 v5.0.3 ## explicit; go 1.23 github.com/cenkalti/backoff/v5 +# github.com/charmbracelet/x/term v0.2.2 +## explicit; go 1.24.0 +github.com/charmbracelet/x/term # github.com/containerd/nri v0.11.0 ## explicit; go 1.24.0 github.com/containerd/nri/pkg/net @@ -101,7 +104,7 @@ golang.org/x/mod/internal/lazyregexp golang.org/x/mod/modfile golang.org/x/mod/module golang.org/x/mod/semver -# golang.org/x/sys v0.45.0 +# golang.org/x/sys v0.46.0 ## explicit; go 1.25.0 golang.org/x/sys/cpu golang.org/x/sys/unix diff --git a/x/go.mod b/x/go.mod index 847b9fa2..da75bf1f 100644 --- a/x/go.mod +++ b/x/go.mod @@ -10,7 +10,7 @@ require ( github.com/spf13/pflag v1.0.9 github.com/stretchr/testify v1.11.1 golang.org/x/mod v0.36.0 - golang.org/x/sys v0.45.0 + golang.org/x/sys v0.46.0 google.golang.org/protobuf v1.36.11 ) diff --git a/x/go.sum b/x/go.sum index 818cd662..0e741a4d 100644 --- a/x/go.sum +++ b/x/go.sum @@ -40,8 +40,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From aeb5122c2ee3433965f28e258aa5c49d3060203c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Gro=C3=9Fmann?= Date: Fri, 9 Oct 2026 07:50:58 +0200 Subject: [PATCH 2/9] fix(pass): reject a late escape tail instead of skipping it as a key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A '[' or 'O' arriving within 500 ms of a lone ESC is taken for the tail of a sequence split from its ESC by a slow link. Until now the tail was skipped when it ended the way a key's sequence does, A-H, P-S, Z, a-d or '~', and rejected otherwise. Escape out of habit followed by typing that ssh latency coalesces into one read, "[a" or "OH", takes exactly that shape, so the typed characters were dropped with nothing but missing '*' to show for it. No whitelist tells the two apart; the prompt's rule is that whatever would alter the value in silence is an error. A late tail is now an error unless it opens a bracketed paste, which is unmistakable. An arrow key split from its ESC asks for the value again rather than passing unseen. Signed-off-by: Johannes Großmann --- plugins/pass/commands/prompt.go | 19 ++++++------ plugins/pass/commands/prompt_test.go | 43 +++++++++------------------- 2 files changed, 22 insertions(+), 40 deletions(-) diff --git a/plugins/pass/commands/prompt.go b/plugins/pass/commands/prompt.go index 50172aee..c5c44131 100644 --- a/plugins/pass/commands/prompt.go +++ b/plugins/pass/commands/prompt.go @@ -333,8 +333,9 @@ func (lr *lineReader) decodeRune() (rune, int, error) { // readRune returns the next rune. Invalid UTF-8 is errInvalidUTF8, unless a // Ctrl-C queued behind it cancels. A '[' or 'O' within tailTimeout of a lone // ESC is its sequence's tail and comes back as that ESC, unconsumed, with -// lr.tail set: escape then takes only a sequence a key sends, as '[' or 'O' -// typed after Escape would otherwise pass for one and be lost in silence. +// lr.tail set: escape then takes a paste from it and nothing else, since a +// key's sequence split from its ESC and '[' or 'O' typed after Escape look +// alike, and skipping the pair as a key would lose the text in silence. func (lr *lineReader) readRune() (rune, error) { r, size, err := lr.decodeRune() if err != nil { @@ -361,7 +362,7 @@ func (lr *lineReader) unreadByte() { lr.r-- } // is returned with ok set, ESC alone is the Escape key. A control byte ends // the burst and stays unread, so Enter and Ctrl-C get through. Printable // bytes that complete no sequence or trail one are errSwallowedInput, as is -// a late tail's sequence that no key sends. +// a late tail's sequence unless it opens a paste. func (lr *lineReader) escape() (paste []byte, ok bool, err error) { tail := lr.tail for { @@ -394,7 +395,7 @@ func (lr *lineReader) escape() (paste []byte, ok bool, err error) { // sequence parses one sequence after ESC: errEscapeKey for ESC alone or // before a control key, errChord for bytes that complete no sequence. With -// tail set, the sequence must be one a key sends. +// tail set, only a paste passes. func (lr *lineReader) sequence(tail bool) (paste []byte, ok bool, err error) { for { b, err := lr.readByte(escTimeout) @@ -438,11 +439,11 @@ func (lr *lineReader) csi(tail bool) (paste []byte, ok bool, err error) { params = append(params, c) case c < 0x40 || c > 0x7e: // no sequence holds this byte return nil, false, lr.cut(c) - case tail && !keyFinal(c): // no key sends this: typed after Escape - return nil, false, errSwallowedInput case c == '~' && string(params) == pasteStart: paste, err = lr.readPaste() return paste, err == nil, err + case tail: // a key's sequence or typing after Escape: no telling + return nil, false, errSwallowedInput case c == 'M' && len(params) == 0: // X10 mouse report return nil, false, lr.skipMouseReport() default: // final byte @@ -458,16 +459,12 @@ func (lr *lineReader) skipFinal(tail bool) error { return err case isControl(c): return lr.cut(c) - case tail && !keyFinal(c): // no key sends this: typed after Escape + case tail: // a key's sequence or typing after Escape: no telling return errSwallowedInput } return nil } -func keyFinal(c byte) bool { - return 'A' <= c && c <= 'H' || 'P' <= c && c <= 'S' || c == 'Z' || c == '~' || 'a' <= c && c <= 'd' -} - func (lr *lineReader) skipMouseReport() error { for range 3 { c, err := lr.readByte(escTimeout) diff --git a/plugins/pass/commands/prompt_test.go b/plugins/pass/commands/prompt_test.go index 1dc6e983..154e7043 100644 --- a/plugins/pass/commands/prompt_test.go +++ b/plugins/pass/commands/prompt_test.go @@ -405,15 +405,15 @@ func Test_readSecretLine(t *testing.T) { // of a lone ESC. func Test_readSecretLine_lateTail(t *testing.T) { t.Parallel() - t.Run("a sequence tail late after its ESC is still skipped", func(t *testing.T) { + t.Run("a paste split from its ESC is taken", func(t *testing.T) { t.Parallel() - // A slow link splits ESC from "[D" for longer than escTimeout, yet - // within tailTimeout. - in := &chunkReader{chunks: []chunk{{data: "hunter2\x1b"}, {delay: 6 * escTimeout, data: "[D"}, {delay: 6 * escTimeout, data: "\r"}}} + // A slow link splits ESC from the paste start for longer than + // escTimeout, yet within tailTimeout. + in := &chunkReader{chunks: []chunk{{data: "\x1b"}, {delay: 6 * escTimeout, data: "[200~hunter2\x1b[201~"}, {delay: 6 * escTimeout, data: "a\r"}}} val, echo, err := read(t, in) require.NoError(t, err) - assert.Equal(t, "hunter2", val) - assert.Equal(t, "*******", echo) + assert.Equal(t, "hunter2a", val) + assert.Equal(t, "********", echo) }) t.Run("a bracket typed well after escape is text", func(t *testing.T) { t.Parallel() @@ -428,31 +428,16 @@ func Test_readSecretLine_lateTail(t *testing.T) { _, _, err := read(t, in) assert.ErrorIs(t, err, errSwallowedInput) }) - t.Run("a late tail a key sends is skipped", func(t *testing.T) { + t.Run("any other late tail is an error", func(t *testing.T) { t.Parallel() // Up, Home as SS3, F5, Shift-Tab, F1 as SS3 and on the Linux - // console, rxvt's shift+up, ctrl+left; a paste split from its ESC. - for _, tail := range []string{"[A", "OH", "[15~", "[Z", "OP", "[[A", "[a", "[1;5D", "[200~hunter2\x1b[201~"} { - t.Run(tail, func(t *testing.T) { - t.Parallel() - in := &chunkReader{chunks: []chunk{{data: "\x1b"}, {delay: 6 * escTimeout, data: tail}, {delay: 6 * escTimeout, data: "a\r"}}} - val, _, err := read(t, in) - require.NoError(t, err) - want := "a" - if strings.HasPrefix(tail, "[200~") { - want = "hunter2a" - } - assert.Equal(t, want, val) - }) - } - }) - t.Run("a late tail that no key sends is an error", func(t *testing.T) { - t.Parallel() - // Escape out of habit, then a value opening with 'O' or '[' whose - // second key ssh latency coalesces with the first and a gap follows: - // an SS3 or CSI no key sends. Also a mouse report, X10 and SGR, and - // a terminal reply split from their ESC, since they end the same way. - for _, tail := range []string{"Op", "[x", "[M !!", "[<0;10;20M", "[0n"} { + // console, rxvt's shift+up and ctrl+left, split from their ESC by + // a slow link; a mouse report, X10 and SGR, and a terminal reply + // likewise. Escape out of habit, then a value opening with '[' or + // 'O' whose second key ssh latency coalesces with the first and a + // gap follows, looks the same whether or not a key sends the pair, + // so none of them is skipped in silence. + for _, tail := range []string{"[A", "OH", "[15~", "[Z", "OP", "[[A", "[a", "[1;5D", "[M !!", "[<0;10;20M", "[0n", "Op", "[x"} { t.Run(tail, func(t *testing.T) { t.Parallel() in := &chunkReader{chunks: []chunk{{data: "abc\x1b"}, {delay: 6 * escTimeout, data: tail}, {delay: 6 * escTimeout, data: "enSesame\r"}}} From 9af3ef71926645711b6d342cd58daa29c8dbd80b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Gro=C3=9Fmann?= Date: Fri, 9 Oct 2026 08:15:08 +0200 Subject: [PATCH 3/9] fix(pass): keep input queued before the prompt instead of flushing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raw mode was entered with TCSAFLUSH, which discards input received before the prompt was up. A value a script sends through a pty before the prompt appears, as expect, script and docker run -t do, was lost, and the prompt waited for keystrokes that never came. Raw mode is now entered with TCSANOW, so queued input is read. A human's type-ahead was echoed by the cooked tty either way; discarding it never took it off the screen. The terminal is still restored with TCSAFLUSH, so input arriving after Enter, a paste's tail among it, never reaches the shell. The Windows console follows: no flush on entry, flush on restore. Signed-off-by: Johannes Großmann --- plugins/pass/commands/prompt.go | 32 -------------- plugins/pass/commands/prompt_darwin.go | 7 ++- plugins/pass/commands/prompt_linux.go | 7 ++- plugins/pass/commands/prompt_test.go | 58 +------------------------ plugins/pass/commands/prompt_unix.go | 2 +- plugins/pass/commands/prompt_windows.go | 3 -- 6 files changed, 14 insertions(+), 95 deletions(-) diff --git a/plugins/pass/commands/prompt.go b/plugins/pass/commands/prompt.go index c5c44131..5b3e32e7 100644 --- a/plugins/pass/commands/prompt.go +++ b/plugins/pass/commands/prompt.go @@ -74,17 +74,6 @@ type input interface { wait(d time.Duration) (ready bool, err error) } -// secretFromPrompt reads one masked line from the terminal as the value for -// id. Raw mode is entered and left with TCSAFLUSH, as getpass(3) does, so -// echoed type-ahead is dropped and a paste's tail never reaches the shell. -// Printable runes echo as '*', Backspace and ctrl+u edit, Enter submits, -// Ctrl-C cancels with context.Canceled, Ctrl-D or EOF is io.ErrUnexpectedEOF, -// escape sequences and unbound control keys are skipped. Whatever else would -// alter the value in silence is an error: a chord that swallowed text, input -// trailing Enter, a paste spanning lines, control characters, invalid UTF-8. -// Cancelling ctx restores the terminal and returns ctx.Err(); a terminal that -// cannot be restored is an error even after a value was read. No read of the -// terminal outlives the prompt: the line typed next is the shell's. func secretFromPrompt(ctx context.Context, in *os.File, out io.Writer, id secrets.ID) (s *setPayload, err error) { src, err := newTerminalInput(ctx, in) if err != nil { @@ -156,7 +145,6 @@ func (ed *lineEditor) emit(s string) { _, _ = io.WriteString(ed.echo, s) } func (ed *lineEditor) insert(r rune) { if len(ed.val) == cap(ed.val) { - // append would retire the full array as is: grow by hand and zero it grown := make([]rune, len(ed.val), max(2*cap(ed.val), 64)) copy(grown, ed.val) clear(ed.val) @@ -224,8 +212,6 @@ func (ed *lineEditor) submit(lr *lineReader) (string, error) { return string(ed.val), nil } -// zero clears the runes collected, the backing array whole: a rune dropped -// since the last growth may sit past the length. func (ed *lineEditor) zero() { clear(ed.val[:cap(ed.val)]) } type lineReader struct { @@ -246,8 +232,6 @@ func (lr *lineReader) zero() { lr.r, lr.w = 0, 0 } -// trailing reports input other than line breaks within escTimeout of the -// line end. A Ctrl-C among it cancels, as it does everywhere else. func (lr *lineReader) trailing() (bool, error) { deadline := time.Now().Add(escTimeout) for { @@ -272,8 +256,6 @@ func (lr *lineReader) trailing() (bool, error) { } } -// fill awaits input for wait, without limit when wait <= 0, and reads what -// came. It reports false when the wait ran out, with no read left in flight. func (lr *lineReader) fill(wait time.Duration) (bool, error) { if lr.r > 0 { lr.w = copy(lr.buf[:], lr.buf[lr.r:lr.w]) @@ -330,12 +312,6 @@ func (lr *lineReader) decodeRune() (rune, int, error) { return r, size, nil } -// readRune returns the next rune. Invalid UTF-8 is errInvalidUTF8, unless a -// Ctrl-C queued behind it cancels. A '[' or 'O' within tailTimeout of a lone -// ESC is its sequence's tail and comes back as that ESC, unconsumed, with -// lr.tail set: escape then takes a paste from it and nothing else, since a -// key's sequence split from its ESC and '[' or 'O' typed after Escape look -// alike, and skipping the pair as a key would lose the text in silence. func (lr *lineReader) readRune() (rune, error) { r, size, err := lr.decodeRune() if err != nil { @@ -358,11 +334,6 @@ func (lr *lineReader) readRune() (rune, error) { func (lr *lineReader) unreadByte() { lr.r-- } -// escape parses the burst after ESC: sequences are skipped, a bracketed paste -// is returned with ok set, ESC alone is the Escape key. A control byte ends -// the burst and stays unread, so Enter and Ctrl-C get through. Printable -// bytes that complete no sequence or trail one are errSwallowedInput, as is -// a late tail's sequence unless it opens a paste. func (lr *lineReader) escape() (paste []byte, ok bool, err error) { tail := lr.tail for { @@ -393,9 +364,6 @@ func (lr *lineReader) escape() (paste []byte, ok bool, err error) { } } -// sequence parses one sequence after ESC: errEscapeKey for ESC alone or -// before a control key, errChord for bytes that complete no sequence. With -// tail set, only a paste passes. func (lr *lineReader) sequence(tail bool) (paste []byte, ok bool, err error) { for { b, err := lr.readByte(escTimeout) diff --git a/plugins/pass/commands/prompt_darwin.go b/plugins/pass/commands/prompt_darwin.go index 101767d3..4c4177ba 100644 --- a/plugins/pass/commands/prompt_darwin.go +++ b/plugins/pass/commands/prompt_darwin.go @@ -16,4 +16,9 @@ package commands import "golang.org/x/sys/unix" -const setTermiosFlush = unix.TIOCSETAF +// tcsetattr(3) actions: TCSANOW applies at once, TCSAFLUSH also discards the +// input still queued. +const ( + setTermiosNow = unix.TIOCSETA + setTermiosFlush = unix.TIOCSETAF +) diff --git a/plugins/pass/commands/prompt_linux.go b/plugins/pass/commands/prompt_linux.go index 763b26f9..9437f5cd 100644 --- a/plugins/pass/commands/prompt_linux.go +++ b/plugins/pass/commands/prompt_linux.go @@ -16,4 +16,9 @@ package commands import "golang.org/x/sys/unix" -const setTermiosFlush = unix.TCSETSF +// tcsetattr(3) actions: TCSANOW applies at once, TCSAFLUSH also discards the +// input still queued. +const ( + setTermiosNow = unix.TCSETS + setTermiosFlush = unix.TCSETSF +) diff --git a/plugins/pass/commands/prompt_test.go b/plugins/pass/commands/prompt_test.go index 154e7043..a9f9b34d 100644 --- a/plugins/pass/commands/prompt_test.go +++ b/plugins/pass/commands/prompt_test.go @@ -28,8 +28,6 @@ import ( "github.com/stretchr/testify/require" ) -// read runs the prompt's line reader over in and returns the value, the -// masked echo, and the error. func read(t *testing.T, in io.Reader) (string, string, error) { t.Helper() var echo bytes.Buffer @@ -60,7 +58,6 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("ctrl+d ends the prompt without a value", func(t *testing.T) { t.Parallel() - // On a Windows console Ctrl-Z arrives as EOF and ends it the same way. val, _, err := read(t, strings.NewReader("hunter2\x04\r")) assert.ErrorIs(t, err, io.ErrUnexpectedEOF) assert.Empty(t, val) @@ -112,12 +109,6 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("escape sequences are skipped", func(t *testing.T) { t.Parallel() - // Left arrow (CSI), Home (SS3), F5 (CSI with parameters), SGR - // mouse report, cursor position report; rxvt alt+up (ESC ESC [ - // A), Linux console F1 (ESC [ [ A), X10 mouse report (ESC [ M and - // three bytes), OSC replies ended by BEL and by ST, a DCS reply; - // an OSC reply cut short by an arrow key. Typing resumes after a - // gap. for _, seq := range []string{ "\x1b[D", "\x1bOH", "\x1b[15~", "\x1b[<0;10;20M", "\x1b[12;40R", "\x1b\x1b[A", "\x1b[[A", "\x1b[M !!", "\x1b]11;rgb:0000/0000/0000\x07", "\x1b]52;c;aGVsbG8=\x1b\\", "\x1bP>|xterm(380)\x1b\\", @@ -135,7 +126,6 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("a control string cut short by a paste", func(t *testing.T) { t.Parallel() - // A DCS reply without terminator, then a bracketed paste. val, echo, err := read(t, strings.NewReader("\x1bP>|xterm\x1b[200~b\x1b[201~\r")) require.NoError(t, err) assert.Equal(t, "b", val) @@ -143,9 +133,6 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("text swallowed by an escape is an error", func(t *testing.T) { t.Parallel() - // Alt+h, or Escape coalesced with typing by ssh latency; the same - // where the text opens a CSI or an SS3; alt+ä; alt with a rune cut - // short; an arrow key with typing right behind it. for _, in := range []string{"\x1bhunter2\r", "\x1b[hunter2\r", "\x1bOpenSesame\r", "a\x1bäb\r", "\x1b\xc3", "a\x1b[Dab\r"} { _, _, err := read(t, strings.NewReader(in)) assert.ErrorIs(t, err, errSwallowedInput, "%q", in) @@ -153,7 +140,6 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("a sequence cut short by a key is an error", func(t *testing.T) { t.Parallel() - // Enter, Backspace or Ctrl-D right behind Alt+[ or the like. for _, in := range []string{"a\x1b[\r", "a\x1b[M\r", "a\x1bO\x7f", "a\x1b]11;rgb\r", "a\x1b[12;\x04"} { _, _, err := read(t, strings.NewReader(in)) assert.ErrorIs(t, err, errSwallowedInput, "%q", in) @@ -161,9 +147,6 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("a chord cut short by a gap is an error", func(t *testing.T) { t.Parallel() - // Alt+[ opens a CSI, Alt+Shift+O an SS3, Alt+Shift+P a DCS and - // Alt+] an OSC; a CSI with parameters and an X10 mouse report cut - // short. Nothing follows within escTimeout. for _, chord := range []string{"\x1b[", "\x1bO", "\x1bP", "\x1b]", "\x1b[12;", "\x1b[M "} { t.Run(chord, func(t *testing.T) { t.Parallel() @@ -190,7 +173,6 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("a lone escape is ignored", func(t *testing.T) { t.Parallel() - // Nothing follows ESC within escTimeout: the Escape key. in := &chunkReader{chunks: []chunk{{data: "\x1b"}, {delay: 6 * escTimeout, data: "a\r"}}} val, _, err := read(t, in) require.NoError(t, err) @@ -205,8 +187,6 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("a sequence split across reads is skipped whole", func(t *testing.T) { t.Parallel() - // The rest arrives in the next read, within escTimeout: after ESC, - // or inside the sequence. for _, chunks := range [][]chunk{ {{data: "\x1b"}, {data: "[D"}, {delay: 6 * escTimeout, data: "a\r"}}, {{data: "\x1b[1;"}, {data: "5D"}, {delay: 6 * escTimeout, data: "a\r"}}, @@ -237,8 +217,6 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("input behind the line end in the same read is rejected", func(t *testing.T) { t.Parallel() - // A multi-line paste without bracketing, or type-ahead coalesced - // with Enter. for _, in := range []string{"line1\rline2\r", "line1\nline2", "line1\r\nline2\r\n"} { _, _, err := read(t, strings.NewReader(in)) assert.ErrorIs(t, err, errPastLineEnd, "%q", in) @@ -246,8 +224,6 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("input right behind the line end in a later read is rejected too", func(t *testing.T) { t.Parallel() - // A paste delivered line by line, or chunked at the line end; the - // wait outlasts a bare line break. for _, chunks := range [][]chunk{ {{data: "line1\r"}, {data: "line2\r"}}, {{data: "line1\r"}, {data: "\n"}, {data: "line2\r\n"}}, @@ -258,7 +234,7 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("a quiet escTimeout after enter submits", func(t *testing.T) { t.Parallel() - pr, pw := io.Pipe() // nothing follows Enter: the read blocks like a quiet tty + pr, pw := io.Pipe() t.Cleanup(func() { _ = pw.Close() }) go func() { _, _ = pw.Write([]byte("line1\r")) }() val, _, err := read(t, pr) @@ -267,8 +243,6 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("ctrl+c right behind enter cancels", func(t *testing.T) { t.Parallel() - // Coalesced with Enter by ssh latency, or in the next read within - // escTimeout: Ctrl-C, not input past the line end. _, _, err := read(t, strings.NewReader("secret\r\x03")) assert.ErrorIs(t, err, context.Canceled) _, _, err = read(t, &chunkReader{chunks: []chunk{{data: "secret\r"}, {data: "\x03"}}}) @@ -292,7 +266,6 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("ctrl+c inside a paste cancels", func(t *testing.T) { t.Parallel() - // Also the way out of a paste whose end marker never comes. for _, in := range []string{"\x1b[200~abc\x03def\x1b[201~\r", "\x1b[200~abc\x03"} { _, _, err := read(t, strings.NewReader(in)) assert.ErrorIs(t, err, context.Canceled, "%q", in) @@ -307,7 +280,6 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("a paste stalled for longer than escTimeout completes", func(t *testing.T) { t.Parallel() - // A paste streams at the pace of the link: no timeout inside one. in := &chunkReader{chunks: []chunk{{data: "\x1b[200~abc"}, {delay: 6 * escTimeout, data: "def\x1b[201~\r"}}} val, _, err := read(t, in) require.NoError(t, err) @@ -334,8 +306,6 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("a C1 control rune is an error", func(t *testing.T) { t.Parallel() - // NEL and a Windows-1252 quote mark gone mojibake: no key sends - // one, so it is text gone wrong, typed or pasted unbracketed. for _, in := range []string{"a\u0085b\r", "a\u0092b\r"} { _, echo, err := read(t, strings.NewReader(in)) assert.ErrorIs(t, err, errControlChars, "%q", in) @@ -351,8 +321,6 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("invalid UTF-8 is rejected", func(t *testing.T) { t.Parallel() - // A stray byte, a lead byte without its continuation, before a key - // or before Enter, a lone continuation byte. for _, in := range []string{"a\xffb\r", "a\xc3b\r", "a\xc3\r", "a\x80b\r"} { val, echo, err := read(t, strings.NewReader(in)) assert.ErrorIs(t, err, errInvalidUTF8, "%q", in) @@ -362,7 +330,6 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("a lead byte on its own is invalid", func(t *testing.T) { t.Parallel() - // The rest of the rune never comes: a terminal sends a rune whole. pr, pw := io.Pipe() t.Cleanup(func() { _ = pw.Close() }) go func() { _, _ = pw.Write([]byte("a\xc3")) }() @@ -375,7 +342,6 @@ func Test_readSecretLine(t *testing.T) { _, _, err := read(t, strings.NewReader(in)) assert.ErrorIs(t, err, context.Canceled, "%q", in) } - // Also when it arrives in the next read, within escTimeout. in := &chunkReader{chunks: []chunk{{data: "a\xc3"}, {data: "\x03"}}} _, _, err := read(t, in) assert.ErrorIs(t, err, context.Canceled) @@ -401,14 +367,10 @@ func Test_readSecretLine(t *testing.T) { }) } -// Test_readSecretLine_lateTail covers a '[' or 'O' arriving within tailTimeout -// of a lone ESC. func Test_readSecretLine_lateTail(t *testing.T) { t.Parallel() t.Run("a paste split from its ESC is taken", func(t *testing.T) { t.Parallel() - // A slow link splits ESC from the paste start for longer than - // escTimeout, yet within tailTimeout. in := &chunkReader{chunks: []chunk{{data: "\x1b"}, {delay: 6 * escTimeout, data: "[200~hunter2\x1b[201~"}, {delay: 6 * escTimeout, data: "a\r"}}} val, echo, err := read(t, in) require.NoError(t, err) @@ -430,13 +392,6 @@ func Test_readSecretLine_lateTail(t *testing.T) { }) t.Run("any other late tail is an error", func(t *testing.T) { t.Parallel() - // Up, Home as SS3, F5, Shift-Tab, F1 as SS3 and on the Linux - // console, rxvt's shift+up and ctrl+left, split from their ESC by - // a slow link; a mouse report, X10 and SGR, and a terminal reply - // likewise. Escape out of habit, then a value opening with '[' or - // 'O' whose second key ssh latency coalesces with the first and a - // gap follows, looks the same whether or not a key sends the pair, - // so none of them is skipped in silence. for _, tail := range []string{"[A", "OH", "[15~", "[Z", "OP", "[[A", "[a", "[1;5D", "[M !!", "[<0;10;20M", "[0n", "Op", "[x"} { t.Run(tail, func(t *testing.T) { t.Parallel() @@ -450,8 +405,6 @@ func Test_readSecretLine_lateTail(t *testing.T) { func Test_lineEditor_zero(t *testing.T) { t.Parallel() - // A rune Backspace drops leaves no trace in the backing array, and - // neither do the runes ctrl+u clears or zero() ends with. ed := &lineEditor{echo: io.Discard} for _, r := range "hunter2x" { ed.insert(r) @@ -467,7 +420,6 @@ func Test_lineEditor_zero(t *testing.T) { } ed.zero() assert.Equal(t, make([]rune, cap(ed.val)), ed.val[:cap(ed.val)]) - // An array the value outgrows is retired zeroed, not left to append. ed = &lineEditor{echo: io.Discard} for ed.insert('a'); len(ed.val) < cap(ed.val); { ed.insert('a') @@ -483,7 +435,6 @@ type chunk struct { data string } -// chunkReader returns one chunk per Read, after the chunk's delay, then EOF. type chunkReader struct{ chunks []chunk } func (c *chunkReader) Read(p []byte) (int, error) { @@ -496,10 +447,6 @@ func (c *chunkReader) Read(p []byte) (int, error) { return copy(p, next.data), nil } -// readerInput makes an io.Reader the prompt's input for tests. A plain reader -// cannot be awaited, so a Read runs in a goroutine and the wait is on its -// result; a Read left behind when a test ends is the very thing -// terminalInput spares the terminal. type readerInput struct { ctx context.Context r io.Reader @@ -560,7 +507,6 @@ func (in *readerInput) Read(p []byte) (int, error) { return n, in.res.err } -// fakeInput is an input whose wait is scripted and whose reads are counted. type fakeInput struct { ready bool err error @@ -588,8 +534,6 @@ func Test_lineReader_fill(t *testing.T) { }) t.Run("a wait that runs out issues no read", func(t *testing.T) { t.Parallel() - // Nothing may be left reading the terminal when the prompt returns: - // the line typed next is the shell's. in := &fakeInput{data: "ls\r"} lr := newLineReader(in) more, err := lr.fill(escTimeout) diff --git a/plugins/pass/commands/prompt_unix.go b/plugins/pass/commands/prompt_unix.go index 459e1567..b53018df 100644 --- a/plugins/pass/commands/prompt_unix.go +++ b/plugins/pass/commands/prompt_unix.go @@ -41,7 +41,7 @@ func enterRaw(f *os.File) (*term.State, error) { raw.Cflag |= unix.CS8 raw.Cc[unix.VMIN] = 1 raw.Cc[unix.VTIME] = 0 - if err := unix.IoctlSetTermios(int(f.Fd()), setTermiosFlush, &raw); err != nil { + if err := unix.IoctlSetTermios(int(f.Fd()), setTermiosNow, &raw); err != nil { return nil, err } return state, nil diff --git a/plugins/pass/commands/prompt_windows.go b/plugins/pass/commands/prompt_windows.go index 9d0ba8be..a474d70e 100644 --- a/plugins/pass/commands/prompt_windows.go +++ b/plugins/pass/commands/prompt_windows.go @@ -35,9 +35,6 @@ const ctrlZ = 0x1a // EOF on a console, as the console reader in os takes it var procReadConsoleInputW = windows.NewLazySystemDLL("kernel32.dll").NewProc("ReadConsoleInputW") func enterRaw(f *os.File) (*term.State, error) { - if err := windows.FlushConsoleInputBuffer(windows.Handle(f.Fd())); err != nil { - return nil, err - } return term.MakeRaw(f.Fd()) } From 5a67c30de8d57cd67268296624c49113e19e9730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Gro=C3=9Fmann?= Date: Fri, 9 Oct 2026 08:30:49 +0200 Subject: [PATCH 4/9] fix(pass): erase the mask across the lines the terminal wraps it at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backspace and ctrl+u took a '*' back with "\b \b", which stops at the left margin in xterm, VTE, Terminal.app and conhost. Once the mask wrapped, the first line's stars stayed put, and the only feedback a masked prompt gives misstated the value's length. A '*' written in the last column was worse: those terminals then hold the cursor pending at the margin, and "\b \b" erased the cell to its left. The line editor now keeps the cursor's column against the terminal's width. No '*' goes in the last column; the line is ended by hand before it, so the terminal never wraps the mask itself. An erase in column 0 moves up and onto the last '*' of the line above. The width comes from the output stream when it is a terminal, since a Windows console reports its size on output handles only, and from stdin otherwise. When it cannot be read the echo falls back to "\b \b" as before. Signed-off-by: Johannes Großmann --- plugins/pass/commands/prompt.go | 53 +++++++++++++++++--- plugins/pass/commands/prompt_test.go | 34 ++++++++++++- plugins/pass/commands/prompt_unix_test.go | 12 +---- plugins/pass/commands/prompt_windows_test.go | 2 +- 4 files changed, 80 insertions(+), 21 deletions(-) diff --git a/plugins/pass/commands/prompt.go b/plugins/pass/commands/prompt.go index 5b3e32e7..8b3287ad 100644 --- a/plugins/pass/commands/prompt.go +++ b/plugins/pass/commands/prompt.go @@ -21,11 +21,12 @@ import ( "fmt" "io" "os" - "strings" "time" "unicode" "unicode/utf8" + "github.com/charmbracelet/x/term" + secrets "github.com/docker/secrets-engine/store" ) @@ -94,18 +95,32 @@ func secretFromPrompt(ctx context.Context, in *os.File, out io.Writer, id secret s, err = nil, fmt.Errorf("restoring the terminal: %w; run reset to recover it", rerr) } }() - _, _ = fmt.Fprintf(out, "Enter secret for %s: %s", id.String(), bracketedPasteOn) - val, err := readSecretLine(src, out) + prompt := "Enter secret for " + id.String() + ": " + _, _ = io.WriteString(out, prompt+bracketedPasteOn) + width, col := layout(in, out, prompt) + val, err := readSecretLine(src, out, width, col) if err != nil { return nil, err } return &setPayload{val: val}, nil } -func readSecretLine(in input, echo io.Writer) (string, error) { +func layout(in *os.File, out io.Writer, prompt string) (width, col int) { + tty := in + if f, ok := unwrapFile(out); ok && term.IsTerminal(f.Fd()) { + tty = f + } + width, _, err := term.GetSize(tty.Fd()) + if err != nil || width < 2 { + return 0, 0 + } + return width, utf8.RuneCountInString(prompt) % width +} + +func readSecretLine(in input, echo io.Writer, width, col int) (string, error) { lr := newLineReader(in) defer lr.zero() - ed := &lineEditor{echo: echo} + ed := &lineEditor{echo: echo, width: width, col: col} defer ed.zero() for { r, err := lr.readRune() @@ -137,8 +152,10 @@ func readSecretLine(in input, echo io.Writer) (string, error) { } type lineEditor struct { - val []rune - echo io.Writer + val []rune + echo io.Writer + width int // columns of the terminal; 0 when unknown + col int // the cursor's column, kept while width is set } func (ed *lineEditor) emit(s string) { _, _ = io.WriteString(ed.echo, s) } @@ -151,19 +168,39 @@ func (ed *lineEditor) insert(r rune) { ed.val = grown } ed.val = append(ed.val, r) + if ed.width > 0 && ed.col >= ed.width-1 { + ed.emit("\r\n") // the last column stays blank + ed.col = 0 + } ed.emit("*") + ed.col++ } func (ed *lineEditor) backspace() { if n := len(ed.val); n > 0 { ed.val[n-1] = 0 // not left behind in the backing array ed.val = ed.val[:n-1] + ed.erase() + } +} + +func (ed *lineEditor) erase() { + switch { + case ed.width == 0: + ed.emit("\b \b") + case ed.col == 0: // it ends the line above: up and onto it + ed.col = ed.width - 2 + ed.emit(fmt.Sprintf("\x1b[A\x1b[%dG \b", ed.col+1)) + default: + ed.col-- ed.emit("\b \b") } } func (ed *lineEditor) reset() { - ed.emit(strings.Repeat("\b \b", len(ed.val))) + for range ed.val { + ed.erase() + } ed.zero() ed.val = ed.val[:0] } diff --git a/plugins/pass/commands/prompt_test.go b/plugins/pass/commands/prompt_test.go index a9f9b34d..6b900e70 100644 --- a/plugins/pass/commands/prompt_test.go +++ b/plugins/pass/commands/prompt_test.go @@ -31,7 +31,7 @@ import ( func read(t *testing.T, in io.Reader) (string, string, error) { t.Helper() var echo bytes.Buffer - val, err := readSecretLine(testInput(t.Context(), in), &echo) + val, err := readSecretLine(testInput(t.Context(), in), &echo, 0, 0) return val, echo.String(), err } @@ -72,7 +72,7 @@ func Test_readSecretLine(t *testing.T) { cancel() }() var echo bytes.Buffer - _, err := readSecretLine(testInput(ctx, pr), &echo) + _, err := readSecretLine(testInput(ctx, pr), &echo, 0, 0) assert.ErrorIs(t, err, context.Canceled) assert.Empty(t, echo.String()) }) @@ -107,6 +107,14 @@ func Test_readSecretLine(t *testing.T) { assert.Equal(t, "d", val) assert.Equal(t, "***\b \b\b \b\b \b*", echo) }) + t.Run("backspace erases the mask across the line end", func(t *testing.T) { + t.Parallel() + var echo bytes.Buffer + val, err := readSecretLine(testInput(t.Context(), strings.NewReader("abcd\x7f\x7f\x7fe\r")), &echo, 8, 5) + require.NoError(t, err) + assert.Equal(t, "ae", val) + assert.Equal(t, "**\r\n**\b \b\b \b\x1b[A\x1b[7G \b*", echo.String()) + }) t.Run("escape sequences are skipped", func(t *testing.T) { t.Parallel() for _, seq := range []string{ @@ -403,6 +411,28 @@ func Test_readSecretLine_lateTail(t *testing.T) { }) } +func Test_lineEditor_wrap(t *testing.T) { + t.Parallel() + var echo bytes.Buffer + ed := &lineEditor{echo: &echo, width: 8, col: 5} + for _, r := range "abcd" { + ed.insert(r) + } + assert.Equal(t, "**\r\n**", echo.String()) + echo.Reset() + for range 3 { + ed.backspace() + } + assert.Equal(t, "\b \b\b \b\x1b[A\x1b[7G \b", echo.String()) + assert.Equal(t, 6, ed.col) + echo.Reset() + ed.insert('e') + ed.reset() + assert.Equal(t, "*\b \b\b \b", echo.String()) + assert.Empty(t, ed.val) + assert.Equal(t, 5, ed.col) +} + func Test_lineEditor_zero(t *testing.T) { t.Parallel() ed := &lineEditor{echo: io.Discard} diff --git a/plugins/pass/commands/prompt_unix_test.go b/plugins/pass/commands/prompt_unix_test.go index 25ccc3f1..d31259dc 100644 --- a/plugins/pass/commands/prompt_unix_test.go +++ b/plugins/pass/commands/prompt_unix_test.go @@ -31,8 +31,6 @@ import ( func Test_terminalInput(t *testing.T) { t.Parallel() - // select(2) stands in for poll(2) where that rejects the terminal, as - // macOS does for /dev/tty: both calls get the same tests. for _, selects := range []bool{false, true} { name := "poll(2)" if selects { @@ -56,16 +54,14 @@ func Test_terminalInput(t *testing.T) { start := time.Now() ready, err := in.wait(escTimeout) require.NoError(t, err) - // poll(2) rejects /dev/tty on macOS, and select(2) takes over. assert.Equal(t, runtime.GOOS == "darwin", in.selects) - if !ready { // unless a line typed ahead awaits the shell + if !ready { assert.GreaterOrEqual(t, time.Since(start), escTimeout) } }) } func testTerminalInput(t *testing.T, selects bool) { - // pipe stands in for the tty: pollable, and read by whoever reads next. pipe := func(t *testing.T) (r, w *os.File) { t.Helper() r, w, err := os.Pipe() @@ -76,7 +72,6 @@ func testTerminalInput(t *testing.T, selects bool) { }) return r, w } - // input watches r by the call under test. input := func(t *testing.T, ctx context.Context, r *os.File) *terminalInput { t.Helper() in, err := newTerminalInput(ctx, r) @@ -85,8 +80,6 @@ func testTerminalInput(t *testing.T, selects bool) { in.selects = selects return in } - // readBack is what r holds for its next reader: the shell, once the - // prompt has returned. readBack := func(t *testing.T, r *os.File) string { t.Helper() got := make(chan string, 1) @@ -163,10 +156,9 @@ func testTerminalInput(t *testing.T, selects bool) { _, err := w.WriteString("hunter2\r") require.NoError(t, err) var echo bytes.Buffer - val, err := readSecretLine(in, &echo) + val, err := readSecretLine(in, &echo, 0, 0) require.NoError(t, err) assert.Equal(t, "hunter2", val) - // The shell's line, typed while the store is still busy. _, err = w.WriteString("ls\n") require.NoError(t, err) assert.Equal(t, "ls\n", readBack(t, r)) diff --git a/plugins/pass/commands/prompt_windows_test.go b/plugins/pass/commands/prompt_windows_test.go index 6fa83ddb..ad13398a 100644 --- a/plugins/pass/commands/prompt_windows_test.go +++ b/plugins/pass/commands/prompt_windows_test.go @@ -81,7 +81,7 @@ func Test_terminalInput_push(t *testing.T) { in.push(r) } var echo bytes.Buffer - val, err := readSecretLine(in, &echo) + val, err := readSecretLine(in, &echo, 0, 0) assert.ErrorIs(t, err, errInvalidUTF8) assert.Empty(t, val) assert.Equal(t, "*", echo.String()) From 7eb3664d0af7e877fc796d7332e4e9663bfcd7c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Gro=C3=9Fmann?= Date: Fri, 9 Oct 2026 08:39:11 +0200 Subject: [PATCH 5/9] fix(pass): say that Enter submits when the input ends before it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl-D, Ctrl-Z on a Windows console, or a terminal closed before Enter surfaced as a bare "unexpected EOF", with nothing to tell a user used to the read-to-EOF flow that Enter submits. The prompt now reports "input ended before Enter; only Enter submits the value", inside a paste or an escape sequence too. A Ctrl-D within 50 ms behind Enter was reported as input past the line end, the multi-line paste error. Like the Ctrl-Z the console reader turns into EOF, it carries no input and is ignored, so a script that sends the value, a newline and Ctrl-D stores the value. Signed-off-by: Johannes Großmann --- plugins/pass/commands/prompt.go | 7 ++++--- plugins/pass/commands/prompt_test.go | 14 ++++++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/plugins/pass/commands/prompt.go b/plugins/pass/commands/prompt.go index 8b3287ad..9e0cef36 100644 --- a/plugins/pass/commands/prompt.go +++ b/plugins/pass/commands/prompt.go @@ -37,6 +37,7 @@ var ( errControlChars = errors.New("value contains control characters; pipe it via STDIN instead") errInvalidUTF8 = errors.New("value is not valid UTF-8; pipe it via STDIN instead") errSwallowedInput = errors.New("escape sequence or Alt chord swallowed part of the input; enter the value again") + errNoEnter = errors.New("input ended before Enter; only Enter submits the value") ) const ( @@ -126,7 +127,7 @@ func readSecretLine(in input, echo io.Writer, width, col int) (string, error) { r, err := lr.readRune() switch { case errors.Is(err, io.EOF), err == nil && r == ctrlD: - return "", io.ErrUnexpectedEOF + return "", errNoEnter case err != nil: return "", err case r == '\r' || r == '\n': @@ -276,7 +277,7 @@ func (lr *lineReader) trailing() (bool, error) { switch { case bytes.IndexByte(rest, ctrlC) >= 0: return false, context.Canceled - case len(bytes.Trim(rest, "\r\n")) > 0: + case len(bytes.Trim(rest, "\r\n\x04")) > 0: // Ctrl-D behind Enter is no input return true, nil } wait := time.Until(deadline) @@ -319,7 +320,7 @@ func (lr *lineReader) readByte(wait time.Duration) (byte, error) { more, err := lr.fill(wait) switch { case errors.Is(err, io.EOF): - return 0, io.ErrUnexpectedEOF + return 0, errNoEnter case err != nil: return 0, err case !more: diff --git a/plugins/pass/commands/prompt_test.go b/plugins/pass/commands/prompt_test.go index 6b900e70..9fb099f2 100644 --- a/plugins/pass/commands/prompt_test.go +++ b/plugins/pass/commands/prompt_test.go @@ -53,15 +53,21 @@ func Test_readSecretLine(t *testing.T) { t.Run("EOF before enter does not submit", func(t *testing.T) { t.Parallel() val, _, err := read(t, strings.NewReader("hunter2")) - assert.ErrorIs(t, err, io.ErrUnexpectedEOF) + assert.ErrorIs(t, err, errNoEnter) assert.Empty(t, val) }) t.Run("ctrl+d ends the prompt without a value", func(t *testing.T) { t.Parallel() val, _, err := read(t, strings.NewReader("hunter2\x04\r")) - assert.ErrorIs(t, err, io.ErrUnexpectedEOF) + assert.ErrorIs(t, err, errNoEnter) assert.Empty(t, val) }) + t.Run("ctrl+d behind enter is no input", func(t *testing.T) { + t.Parallel() + val, _, err := read(t, strings.NewReader("hunter2\r\x04")) + require.NoError(t, err) + assert.Equal(t, "hunter2", val) + }) t.Run("cancelling the context ends the read", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(t.Context()) @@ -296,13 +302,13 @@ func Test_readSecretLine(t *testing.T) { t.Run("EOF inside a paste errors", func(t *testing.T) { t.Parallel() _, _, err := read(t, strings.NewReader("\x1b[200~hunter2")) - assert.ErrorIs(t, err, io.ErrUnexpectedEOF) + assert.ErrorIs(t, err, errNoEnter) }) t.Run("EOF inside a sequence errors", func(t *testing.T) { t.Parallel() for _, in := range []string{"\x1b[", "\x1b[12;", "\x1b[[", "\x1bO", "\x1b]11;rgb", "\x1bP>|x", "\x1b[M "} { _, _, err := read(t, strings.NewReader(in)) - assert.ErrorIs(t, err, io.ErrUnexpectedEOF, "%q", in) + assert.ErrorIs(t, err, errNoEnter, "%q", in) } }) t.Run("ctrl+v and other control keys are ignored", func(t *testing.T) { From b1af9397f90254d0520af10433ac1e9bcd453cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Gro=C3=9Fmann?= Date: Fri, 9 Oct 2026 14:02:10 +0200 Subject: [PATCH 6/9] fix(pass): reject a control string cut by Escape then a key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An OSC, DCS or like string cut by Enter alone was an error, as its text may have been typed, yet the same string cut by Escape then Enter passed in silence: skipString handed the ESC's follower back as a new sequence, and sequence took a control byte there for the Escape key. Alt+] followed by typing, Escape and Enter stored the value short of that typing. Escape pressed twice leaked the same way, through the rxvt ESC ESC prefix. skipString now consumes the run of ESCs itself: ST ends the string, a control byte cuts it, Ctrl-C cancelling and anything else an error, and only a printable byte opens the next sequence. Signed-off-by: Johannes Großmann --- plugins/pass/commands/prompt.go | 13 +++++++++---- plugins/pass/commands/prompt_test.go | 6 +++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/plugins/pass/commands/prompt.go b/plugins/pass/commands/prompt.go index 9e0cef36..098de4b3 100644 --- a/plugins/pass/commands/prompt.go +++ b/plugins/pass/commands/prompt.go @@ -492,11 +492,16 @@ func (lr *lineReader) skipString(bel bool) (newSequence bool, err error) { return false, err case c == esc: d, err := lr.readByte(escTimeout) - if err != nil { - return false, err + for d == esc && err == nil { // Escape again; rxvt's ESC ESC [ A + d, err = lr.readByte(escTimeout) } - if d == '\\' { - return false, nil // ST + switch { + case err != nil: + return false, err + case d == '\\': // ST + return false, nil + case isControl(d): // Escape then a key cut the string + return false, lr.cut(d) } lr.unreadByte() return true, nil diff --git a/plugins/pass/commands/prompt_test.go b/plugins/pass/commands/prompt_test.go index 9fb099f2..af9cf837 100644 --- a/plugins/pass/commands/prompt_test.go +++ b/plugins/pass/commands/prompt_test.go @@ -126,7 +126,7 @@ func Test_readSecretLine(t *testing.T) { for _, seq := range []string{ "\x1b[D", "\x1bOH", "\x1b[15~", "\x1b[<0;10;20M", "\x1b[12;40R", "\x1b\x1b[A", "\x1b[[A", "\x1b[M !!", "\x1b]11;rgb:0000/0000/0000\x07", "\x1b]52;c;aGVsbG8=\x1b\\", "\x1bP>|xterm(380)\x1b\\", - "\x1b]11;rgb\x1b[D", + "\x1b]11;rgb\x1b[D", "\x1b]11;rgb\x1b\x1b[D", } { t.Run(seq, func(t *testing.T) { t.Parallel() @@ -154,7 +154,7 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("a sequence cut short by a key is an error", func(t *testing.T) { t.Parallel() - for _, in := range []string{"a\x1b[\r", "a\x1b[M\r", "a\x1bO\x7f", "a\x1b]11;rgb\r", "a\x1b[12;\x04"} { + for _, in := range []string{"a\x1b[\r", "a\x1b[M\r", "a\x1bO\x7f", "a\x1b]11;rgb\r", "a\x1b[12;\x04", "a\x1b]11;rgb\x1b\r", "a\x1bP>|xterm\x1b\x7f", "a\x1b]11;rgb\x1b\x1b\r"} { _, _, err := read(t, strings.NewReader(in)) assert.ErrorIs(t, err, errSwallowedInput, "%q", in) } @@ -180,7 +180,7 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("ctrl+c ends a sequence cut short", func(t *testing.T) { t.Parallel() - for _, in := range []string{"\x1b[\x03", "\x1b[12;\x03", "\x1b[[\x03", "\x1bO\x03", "\x1b]11;rgb\x03", "\x1bP>|xterm\x03", "\x1b[M \x03"} { + for _, in := range []string{"\x1b[\x03", "\x1b[12;\x03", "\x1b[[\x03", "\x1bO\x03", "\x1b]11;rgb\x03", "\x1b]11;rgb\x1b\x03", "\x1bP>|xterm\x03", "\x1b[M \x03"} { _, _, err := read(t, strings.NewReader(in)) assert.ErrorIs(t, err, context.Canceled, "%q", in) } From ed9356de37926cc37bc52ff8bbad9a88efcaadfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Gro=C3=9Fmann?= Date: Fri, 9 Oct 2026 14:08:55 +0200 Subject: [PATCH 7/9] fix(pass): take Ctrl-D for the end of input inside a paste or a sequence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl-D ended the input only at the top of the read loop. Inside a bracketed paste it was content, and readPaste went on waiting for the end marker; inside an escape sequence it cut the sequence and was reported as swallowed input. A closed terminal and a Windows Ctrl-Z already ended the input at those spots. The line reader now cuts what it read at a Ctrl-D and reports EOF from then on, as a cooked tty does, so every path ends in the same error; what followed the Ctrl-D is the shell's. Signed-off-by: Johannes Großmann --- plugins/pass/commands/prompt.go | 15 +++++++++++++-- plugins/pass/commands/prompt_test.go | 17 ++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/plugins/pass/commands/prompt.go b/plugins/pass/commands/prompt.go index 098de4b3..624494bf 100644 --- a/plugins/pass/commands/prompt.go +++ b/plugins/pass/commands/prompt.go @@ -126,7 +126,7 @@ func readSecretLine(in input, echo io.Writer, width, col int) (string, error) { for { r, err := lr.readRune() switch { - case errors.Is(err, io.EOF), err == nil && r == ctrlD: + case errors.Is(err, io.EOF): return "", errNoEnter case err != nil: return "", err @@ -261,6 +261,8 @@ type lineReader struct { // tail is set when the ESC readRune last returned stood for a '[' or // 'O' taken for that ESC's late tail. tail bool + // eof is set once a Ctrl-D ended the input: fill is io.EOF from then on. + eof bool } func newLineReader(src input) *lineReader { return &lineReader{src: src} } @@ -277,7 +279,7 @@ func (lr *lineReader) trailing() (bool, error) { switch { case bytes.IndexByte(rest, ctrlC) >= 0: return false, context.Canceled - case len(bytes.Trim(rest, "\r\n\x04")) > 0: // Ctrl-D behind Enter is no input + case len(bytes.Trim(rest, "\r\n")) > 0: return true, nil } wait := time.Until(deadline) @@ -295,6 +297,9 @@ func (lr *lineReader) trailing() (bool, error) { } func (lr *lineReader) fill(wait time.Duration) (bool, error) { + if lr.eof { + return false, io.EOF + } if lr.r > 0 { lr.w = copy(lr.buf[:], lr.buf[lr.r:lr.w]) lr.r = 0 @@ -304,10 +309,16 @@ func (lr *lineReader) fill(wait time.Duration) (bool, error) { return false, err } n, err := lr.src.Read(lr.buf[lr.w:]) + if i := bytes.IndexByte(lr.buf[lr.w:lr.w+n], ctrlD); i >= 0 { + clear(lr.buf[lr.w+i : lr.w+n]) + n, lr.eof = i, true + } lr.w += n switch { case n > 0: return true, nil + case lr.eof: + return false, io.EOF case err == nil: return false, io.ErrNoProgress default: diff --git a/plugins/pass/commands/prompt_test.go b/plugins/pass/commands/prompt_test.go index af9cf837..805e5258 100644 --- a/plugins/pass/commands/prompt_test.go +++ b/plugins/pass/commands/prompt_test.go @@ -154,7 +154,7 @@ func Test_readSecretLine(t *testing.T) { }) t.Run("a sequence cut short by a key is an error", func(t *testing.T) { t.Parallel() - for _, in := range []string{"a\x1b[\r", "a\x1b[M\r", "a\x1bO\x7f", "a\x1b]11;rgb\r", "a\x1b[12;\x04", "a\x1b]11;rgb\x1b\r", "a\x1bP>|xterm\x1b\x7f", "a\x1b]11;rgb\x1b\x1b\r"} { + for _, in := range []string{"a\x1b[\r", "a\x1b[M\r", "a\x1bO\x7f", "a\x1b]11;rgb\r", "a\x1b]11;rgb\x1b\r", "a\x1bP>|xterm\x1b\x7f", "a\x1b]11;rgb\x1b\x1b\r"} { _, _, err := read(t, strings.NewReader(in)) assert.ErrorIs(t, err, errSwallowedInput, "%q", in) } @@ -381,6 +381,21 @@ func Test_readSecretLine(t *testing.T) { }) } +func Test_readSecretLine_ctrlD(t *testing.T) { + t.Parallel() + for _, in := range []string{"\x1b[200~hunter2\x04", "\x1b[12;\x04", "\x1b]11;rgb\x04", "\x1b[M \x04", "ab\x04cd\r"} { + t.Run(in, func(t *testing.T) { + t.Parallel() + pr, pw := io.Pipe() + t.Cleanup(func() { _ = pw.Close() }) + go func() { _, _ = pw.Write([]byte(in)) }() + val, _, err := read(t, pr) + assert.ErrorIs(t, err, errNoEnter) + assert.Empty(t, val) + }) + } +} + func Test_readSecretLine_lateTail(t *testing.T) { t.Parallel() t.Run("a paste split from its ESC is taken", func(t *testing.T) { From 9d63d8f1e8f3a29b08040bb1d51da7d87838e853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Gro=C3=9Fmann?= Date: Fri, 9 Oct 2026 14:13:33 +0200 Subject: [PATCH 8/9] fix(pass): let any number of line breaks follow Enter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trailing looked for input behind Enter but never moved past the line breaks it had cleared, so each refill stacked them in the read buffer. Once they filled it, fill handed Read an empty slice, a tty answered with no data and no error, and the prompt failed with a bare "multiple Read calls return no data or error". It took more than 4096 line feeds within 50 ms, delivered unbracketed: expect, tmux send-keys, a serial line or a legacy console. The breaks seen are now consumed before the next read, so the buffer is compacted and never fills with them. Signed-off-by: Johannes Großmann --- plugins/pass/commands/prompt.go | 1 + plugins/pass/commands/prompt_test.go | 6 ++++++ plugins/pass/commands/prompt_unix.go | 2 -- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/plugins/pass/commands/prompt.go b/plugins/pass/commands/prompt.go index 624494bf..86429af9 100644 --- a/plugins/pass/commands/prompt.go +++ b/plugins/pass/commands/prompt.go @@ -282,6 +282,7 @@ func (lr *lineReader) trailing() (bool, error) { case len(bytes.Trim(rest, "\r\n")) > 0: return true, nil } + lr.r = lr.w wait := time.Until(deadline) if wait <= 0 { return false, nil diff --git a/plugins/pass/commands/prompt_test.go b/plugins/pass/commands/prompt_test.go index 805e5258..0cae48f3 100644 --- a/plugins/pass/commands/prompt_test.go +++ b/plugins/pass/commands/prompt_test.go @@ -68,6 +68,12 @@ func Test_readSecretLine(t *testing.T) { require.NoError(t, err) assert.Equal(t, "hunter2", val) }) + t.Run("line breaks behind enter are no input, however many", func(t *testing.T) { + t.Parallel() + val, _, err := read(t, strings.NewReader("hunter2\r"+strings.Repeat("\n", 5000))) + require.NoError(t, err) + assert.Equal(t, "hunter2", val) + }) t.Run("cancelling the context ends the read", func(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(t.Context()) diff --git a/plugins/pass/commands/prompt_unix.go b/plugins/pass/commands/prompt_unix.go index b53018df..53fdcec3 100644 --- a/plugins/pass/commands/prompt_unix.go +++ b/plugins/pass/commands/prompt_unix.go @@ -111,7 +111,6 @@ func (t *terminalInput) wait(d time.Duration) (bool, error) { } } -// watch waits for input or the wake-up for left, without limit when left < 0. func (t *terminalInput) watch(left time.Duration) (bool, error) { if t.selects { return t.selectWatch(left) @@ -138,7 +137,6 @@ func (t *terminalInput) watch(left time.Duration) (bool, error) { return true, nil // input, or a hangup or error for the read to report } -// selectWatch is watch by select(2), for a terminal poll(2) cannot watch. func (t *terminalInput) selectWatch(left time.Duration) (bool, error) { if t.fd >= unix.FD_SETSIZE || t.wake >= unix.FD_SETSIZE { return false, errBeyondSelect From ec5ea962da3c72df5e353e1436231d55999a1a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Gro=C3=9Fmann?= Date: Fri, 9 Oct 2026 14:30:15 +0200 Subject: [PATCH 9/9] refactor(pass): move the masked prompt into commands/internal/prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal line reader and its platform files leave the commands package for one of their own. ReadMasked returns the value as a string and set wraps it into its payload; UnwrapFile is exported, as set uses it to reach the file behind docker's stream wrappers. Signed-off-by: Johannes Großmann --- .../commands/{ => internal/prompt}/prompt.go | 23 ++++++++----------- .../{ => internal/prompt}/prompt_darwin.go | 2 +- .../{ => internal/prompt}/prompt_linux.go | 2 +- .../{ => internal/prompt}/prompt_test.go | 12 +++++----- .../{ => internal/prompt}/prompt_unix.go | 2 +- .../{ => internal/prompt}/prompt_unix_test.go | 2 +- .../{ => internal/prompt}/prompt_windows.go | 2 +- .../prompt}/prompt_windows_test.go | 2 +- plugins/pass/commands/set.go | 7 ++++-- 9 files changed, 27 insertions(+), 27 deletions(-) rename plugins/pass/commands/{ => internal/prompt}/prompt.go (95%) rename plugins/pass/commands/{ => internal/prompt}/prompt_darwin.go (97%) rename plugins/pass/commands/{ => internal/prompt}/prompt_linux.go (97%) rename plugins/pass/commands/{ => internal/prompt}/prompt_test.go (98%) rename plugins/pass/commands/{ => internal/prompt}/prompt_unix.go (99%) rename plugins/pass/commands/{ => internal/prompt}/prompt_unix_test.go (99%) rename plugins/pass/commands/{ => internal/prompt}/prompt_windows.go (99%) rename plugins/pass/commands/{ => internal/prompt}/prompt_windows_test.go (99%) diff --git a/plugins/pass/commands/prompt.go b/plugins/pass/commands/internal/prompt/prompt.go similarity index 95% rename from plugins/pass/commands/prompt.go rename to plugins/pass/commands/internal/prompt/prompt.go index 86429af9..3a4f36e5 100644 --- a/plugins/pass/commands/prompt.go +++ b/plugins/pass/commands/internal/prompt/prompt.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package commands +package prompt import ( "bytes" @@ -62,8 +62,9 @@ var ( errChord = errors.New("escape chord") ) -func unwrapFile(s any) (*os.File, bool) { - // docker's streams.In and streams.Out hide the file behind File(). +// UnwrapFile returns the file behind s: docker's streams.In and streams.Out +// hide it behind File(). +func UnwrapFile(s any) (*os.File, bool) { if d, wrapped := s.(interface{ File() (*os.File, bool) }); wrapped { return d.File() } @@ -76,15 +77,15 @@ type input interface { wait(d time.Duration) (ready bool, err error) } -func secretFromPrompt(ctx context.Context, in *os.File, out io.Writer, id secrets.ID) (s *setPayload, err error) { +func ReadMasked(ctx context.Context, in *os.File, out io.Writer, id secrets.ID) (val string, err error) { src, err := newTerminalInput(ctx, in) if err != nil { - return nil, err + return "", err } defer src.close() state, err := enterRaw(in) if err != nil { - return nil, err + return "", err } defer func() { eol := "\r\n" @@ -93,22 +94,18 @@ func secretFromPrompt(ctx context.Context, in *os.File, out io.Writer, id secret } _, _ = io.WriteString(out, bracketedPasteOff+eol) if rerr := restoreTerminal(in, state); rerr != nil && err == nil { - s, err = nil, fmt.Errorf("restoring the terminal: %w; run reset to recover it", rerr) + val, err = "", fmt.Errorf("restoring the terminal: %w; run reset to recover it", rerr) } }() prompt := "Enter secret for " + id.String() + ": " _, _ = io.WriteString(out, prompt+bracketedPasteOn) width, col := layout(in, out, prompt) - val, err := readSecretLine(src, out, width, col) - if err != nil { - return nil, err - } - return &setPayload{val: val}, nil + return readSecretLine(src, out, width, col) } func layout(in *os.File, out io.Writer, prompt string) (width, col int) { tty := in - if f, ok := unwrapFile(out); ok && term.IsTerminal(f.Fd()) { + if f, ok := UnwrapFile(out); ok && term.IsTerminal(f.Fd()) { tty = f } width, _, err := term.GetSize(tty.Fd()) diff --git a/plugins/pass/commands/prompt_darwin.go b/plugins/pass/commands/internal/prompt/prompt_darwin.go similarity index 97% rename from plugins/pass/commands/prompt_darwin.go rename to plugins/pass/commands/internal/prompt/prompt_darwin.go index 4c4177ba..c699ade0 100644 --- a/plugins/pass/commands/prompt_darwin.go +++ b/plugins/pass/commands/internal/prompt/prompt_darwin.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package commands +package prompt import "golang.org/x/sys/unix" diff --git a/plugins/pass/commands/prompt_linux.go b/plugins/pass/commands/internal/prompt/prompt_linux.go similarity index 97% rename from plugins/pass/commands/prompt_linux.go rename to plugins/pass/commands/internal/prompt/prompt_linux.go index 9437f5cd..8f3f6bb5 100644 --- a/plugins/pass/commands/prompt_linux.go +++ b/plugins/pass/commands/internal/prompt/prompt_linux.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package commands +package prompt import "golang.org/x/sys/unix" diff --git a/plugins/pass/commands/prompt_test.go b/plugins/pass/commands/internal/prompt/prompt_test.go similarity index 98% rename from plugins/pass/commands/prompt_test.go rename to plugins/pass/commands/internal/prompt/prompt_test.go index 0cae48f3..12137f66 100644 --- a/plugins/pass/commands/prompt_test.go +++ b/plugins/pass/commands/internal/prompt/prompt_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package commands +package prompt import ( "bytes" @@ -614,31 +614,31 @@ func Test_lineReader_fill(t *testing.T) { }) } -func Test_unwrapFile(t *testing.T) { +func Test_UnwrapFile(t *testing.T) { t.Parallel() f, err := os.Open(os.DevNull) require.NoError(t, err) t.Cleanup(func() { _ = f.Close() }) t.Run("plain file", func(t *testing.T) { t.Parallel() - got, ok := unwrapFile(f) + got, ok := UnwrapFile(f) require.True(t, ok) assert.Same(t, f, got) }) t.Run("docker stream wrapper", func(t *testing.T) { t.Parallel() - got, ok := unwrapFile(fileWrapper{Reader: &bytes.Buffer{}, f: f}) + got, ok := UnwrapFile(fileWrapper{Reader: &bytes.Buffer{}, f: f}) require.True(t, ok) assert.Same(t, f, got) }) t.Run("wrapper without a file", func(t *testing.T) { t.Parallel() - _, ok := unwrapFile(fileWrapper{Reader: &bytes.Buffer{}}) + _, ok := UnwrapFile(fileWrapper{Reader: &bytes.Buffer{}}) assert.False(t, ok) }) t.Run("buffer", func(t *testing.T) { t.Parallel() - _, ok := unwrapFile(&bytes.Buffer{}) + _, ok := UnwrapFile(&bytes.Buffer{}) assert.False(t, ok) }) } diff --git a/plugins/pass/commands/prompt_unix.go b/plugins/pass/commands/internal/prompt/prompt_unix.go similarity index 99% rename from plugins/pass/commands/prompt_unix.go rename to plugins/pass/commands/internal/prompt/prompt_unix.go index 53fdcec3..0f8a3bc2 100644 --- a/plugins/pass/commands/prompt_unix.go +++ b/plugins/pass/commands/internal/prompt/prompt_unix.go @@ -14,7 +14,7 @@ //go:build !windows -package commands +package prompt import ( "context" diff --git a/plugins/pass/commands/prompt_unix_test.go b/plugins/pass/commands/internal/prompt/prompt_unix_test.go similarity index 99% rename from plugins/pass/commands/prompt_unix_test.go rename to plugins/pass/commands/internal/prompt/prompt_unix_test.go index d31259dc..2995615c 100644 --- a/plugins/pass/commands/prompt_unix_test.go +++ b/plugins/pass/commands/internal/prompt/prompt_unix_test.go @@ -14,7 +14,7 @@ //go:build !windows -package commands +package prompt import ( "bytes" diff --git a/plugins/pass/commands/prompt_windows.go b/plugins/pass/commands/internal/prompt/prompt_windows.go similarity index 99% rename from plugins/pass/commands/prompt_windows.go rename to plugins/pass/commands/internal/prompt/prompt_windows.go index a474d70e..3853e5a0 100644 --- a/plugins/pass/commands/prompt_windows.go +++ b/plugins/pass/commands/internal/prompt/prompt_windows.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package commands +package prompt import ( "bytes" diff --git a/plugins/pass/commands/prompt_windows_test.go b/plugins/pass/commands/internal/prompt/prompt_windows_test.go similarity index 99% rename from plugins/pass/commands/prompt_windows_test.go rename to plugins/pass/commands/internal/prompt/prompt_windows_test.go index ad13398a..7c8dd186 100644 --- a/plugins/pass/commands/prompt_windows_test.go +++ b/plugins/pass/commands/internal/prompt/prompt_windows_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package commands +package prompt import ( "bytes" diff --git a/plugins/pass/commands/set.go b/plugins/pass/commands/set.go index 294552d4..6b00c1ad 100644 --- a/plugins/pass/commands/set.go +++ b/plugins/pass/commands/set.go @@ -26,6 +26,7 @@ import ( "github.com/charmbracelet/x/term" "github.com/spf13/cobra" + "github.com/docker/secrets-engine/plugins/pass/commands/internal/prompt" pass "github.com/docker/secrets-engine/plugins/pass/store" "github.com/docker/secrets-engine/x/secrets" ) @@ -71,11 +72,13 @@ func SetCommand() *cobra.Command { } var s *setPayload - switch in, isFile := unwrapFile(cmd.InOrStdin()); { + switch in, isFile := prompt.UnwrapFile(cmd.InOrStdin()); { case isNotImplicitReadFromStdinSyntax(args): s = &setPayload{val: value} case isFile && term.IsTerminal(in.Fd()): - s, err = secretFromPrompt(cmd.Context(), in, cmd.ErrOrStderr(), id) + var val string + val, err = prompt.ReadMasked(cmd.Context(), in, cmd.ErrOrStderr(), id) + s = &setPayload{val: val} default: s, err = secretMappingFromSTDIN(cmd.Context(), cmd.InOrStdin()) }