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/internal/prompt/prompt.go b/plugins/pass/commands/internal/prompt/prompt.go new file mode 100644 index 00000000..3a4f36e5 --- /dev/null +++ b/plugins/pass/commands/internal/prompt/prompt.go @@ -0,0 +1,550 @@ +// 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 prompt + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "time" + "unicode" + "unicode/utf8" + + "github.com/charmbracelet/x/term" + + 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") + errNoEnter = errors.New("input ended before Enter; only Enter submits the value") +) + +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") +) + +// 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() + } + f, ok := s.(*os.File) + return f, ok +} + +type input interface { + io.Reader + wait(d time.Duration) (ready bool, 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 "", err + } + defer src.close() + state, err := enterRaw(in) + if err != nil { + return "", 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 { + 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) + 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()) { + 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, width: width, col: col} + defer ed.zero() + for { + r, err := lr.readRune() + switch { + case errors.Is(err, io.EOF): + return "", errNoEnter + 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 + 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) } + +func (ed *lineEditor) insert(r rune) { + if len(ed.val) == cap(ed.val) { + 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) + 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() { + for range ed.val { + ed.erase() + } + 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 +} + +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 + // 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} } + +func (lr *lineReader) zero() { + clear(lr.buf[:]) + lr.r, lr.w = 0, 0 +} + +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 + } + lr.r = lr.w + 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 + } + } +} + +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 + } + ready, err := lr.src.wait(wait) + if err != nil || !ready { + 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: + 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, errNoEnter + 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 +} + +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-- } + +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 + } + } +} + +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 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 + 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: // a key's sequence or typing after Escape: no telling + return errSwallowedInput + } + return nil +} + +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) + for d == esc && err == nil { // Escape again; rxvt's ESC ESC [ A + d, err = lr.readByte(escTimeout) + } + 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 + 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/internal/prompt/prompt_darwin.go b/plugins/pass/commands/internal/prompt/prompt_darwin.go new file mode 100644 index 00000000..c699ade0 --- /dev/null +++ b/plugins/pass/commands/internal/prompt/prompt_darwin.go @@ -0,0 +1,24 @@ +// 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 prompt + +import "golang.org/x/sys/unix" + +// 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/internal/prompt/prompt_linux.go b/plugins/pass/commands/internal/prompt/prompt_linux.go new file mode 100644 index 00000000..8f3f6bb5 --- /dev/null +++ b/plugins/pass/commands/internal/prompt/prompt_linux.go @@ -0,0 +1,24 @@ +// 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 prompt + +import "golang.org/x/sys/unix" + +// 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/internal/prompt/prompt_test.go b/plugins/pass/commands/internal/prompt/prompt_test.go new file mode 100644 index 00000000..12137f66 --- /dev/null +++ b/plugins/pass/commands/internal/prompt/prompt_test.go @@ -0,0 +1,651 @@ +// 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 prompt + +import ( + "bytes" + "context" + "io" + "os" + "strings" + "testing" + "testing/iotest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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, 0, 0) + 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, 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, 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("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()) + 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, 0, 0) + 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("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{ + "\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\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() + 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() + 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() + 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) + } + }) + t.Run("a chord cut short by a gap is an error", func(t *testing.T) { + t.Parallel() + 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", "\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) + } + }) + t.Run("a lone escape is ignored", func(t *testing.T) { + t.Parallel() + 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() + 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() + 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() + 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() + 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() + _, _, 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() + 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() + 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, 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, errNoEnter, "%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() + 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() + 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() + 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) + } + 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) + }) +} + +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) { + t.Parallel() + 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, "hunter2a", 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("any other late tail is an error", func(t *testing.T) { + t.Parallel() + 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"}}} + _, _, err := read(t, in) + assert.ErrorIs(t, err, errSwallowedInput) + }) + } + }) +} + +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} + 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)]) + 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 +} + +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 +} + +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 +} + +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() + 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/internal/prompt/prompt_unix.go b/plugins/pass/commands/internal/prompt/prompt_unix.go new file mode 100644 index 00000000..0f8a3bc2 --- /dev/null +++ b/plugins/pass/commands/internal/prompt/prompt_unix.go @@ -0,0 +1,162 @@ +// 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 prompt + +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()), setTermiosNow, &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 + } +} + +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 +} + +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/internal/prompt/prompt_unix_test.go b/plugins/pass/commands/internal/prompt/prompt_unix_test.go new file mode 100644 index 00000000..2995615c --- /dev/null +++ b/plugins/pass/commands/internal/prompt/prompt_unix_test.go @@ -0,0 +1,166 @@ +// 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 prompt + +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() + 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) + assert.Equal(t, runtime.GOOS == "darwin", in.selects) + if !ready { + assert.GreaterOrEqual(t, time.Since(start), escTimeout) + } + }) +} + +func testTerminalInput(t *testing.T, selects bool) { + 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 := 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 := 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, 0, 0) + require.NoError(t, err) + assert.Equal(t, "hunter2", val) + _, err = w.WriteString("ls\n") + require.NoError(t, err) + assert.Equal(t, "ls\n", readBack(t, r)) + }) +} diff --git a/plugins/pass/commands/internal/prompt/prompt_windows.go b/plugins/pass/commands/internal/prompt/prompt_windows.go new file mode 100644 index 00000000..3853e5a0 --- /dev/null +++ b/plugins/pass/commands/internal/prompt/prompt_windows.go @@ -0,0 +1,205 @@ +// 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 prompt + +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) { + 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/internal/prompt/prompt_windows_test.go b/plugins/pass/commands/internal/prompt/prompt_windows_test.go new file mode 100644 index 00000000..7c8dd186 --- /dev/null +++ b/plugins/pass/commands/internal/prompt/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 prompt + +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, 0, 0) + 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..6b00c1ad 100644 --- a/plugins/pass/commands/set.go +++ b/plugins/pass/commands/set.go @@ -23,8 +23,10 @@ import ( "maps" "strings" + "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" ) @@ -37,7 +39,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 +61,27 @@ 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 := prompt.UnwrapFile(cmd.InOrStdin()); { + case isNotImplicitReadFromStdinSyntax(args): + s = &setPayload{val: value} + case isFile && term.IsTerminal(in.Fd()): + 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()) + } if err != nil { return err } @@ -129,7 +132,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 +141,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=