From 8913e33536f9b8b45c549c523813e345e601ce50 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 18 Apr 2025 12:44:31 +0000 Subject: [PATCH 01/13] remove speakeasy dependency; implement input recapture --- cli/cliui/prompt.go | 52 +++++++++++++++++++++++++++++++++-- cli/cliui/prompt_test.go | 44 +++++++++++++++++++---------- cli/login.go | 1 + cli/server_createadminuser.go | 2 ++ cmd/cliui/main.go | 1 + go.mod | 1 - go.sum | 2 -- 7 files changed, 82 insertions(+), 21 deletions(-) diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index b432f75afeaaf..25314c89192db 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -9,8 +9,8 @@ import ( "os/signal" "strings" - "github.com/bgentry/speakeasy" "github.com/mattn/go-isatty" + "golang.org/x/term" "golang.org/x/xerrors" "github.com/coder/pretty" @@ -22,6 +22,7 @@ type PromptOptions struct { Text string Default string Secret bool + Mask rune IsConfirm bool Validate func(string) error } @@ -90,8 +91,53 @@ func Prompt(inv *serpent.Invocation, opts PromptOptions) (string, error) { inFile, isInputFile := inv.Stdin.(*os.File) if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) { - // we don't install a signal handler here because speakeasy has its own - line, err = speakeasy.Ask("") + // Set terminal to raw mode + oldState, err := term.MakeRaw(int(inFile.Fd())) + if err != nil { + errCh <- err + return + } + defer func() { + _ = term.Restore(int(inFile.Fd()), oldState) + }() + + // Read input character by character + buf := make([]byte, 1) + for { + n, err := inv.Stdin.Read(buf) + if err != nil || n == 0 { + break + } + + // Handle special characters + switch buf[0] { + case '\r', '\n': // Enter + _, _ = fmt.Fprint(inv.Stdout, "\n") + lineCh <- line + return + case 3: // Ctrl+C + _, _ = fmt.Fprint(inv.Stdout, "\n") + errCh <- ErrCanceled + return + case 8, 127: // Backspace/Delete + if len(line) > 0 { + line = line[:len(line)-1] + // Move cursor back, print space, move cursor back again + _, _ = fmt.Fprint(inv.Stdout, "\b \b") + } + default: + // Only append printable characters + if buf[0] >= 32 && buf[0] <= 126 { + line += string(buf[0]) + // Print the mask character + if opts.Mask == 0 { + _, _ = fmt.Fprint(inv.Stdout, "") + } else { + _, _ = fmt.Fprint(inv.Stdout, string(opts.Mask)) + } + } + } + } } else { signal.Notify(interrupt, os.Interrupt) defer signal.Stop(interrupt) diff --git a/cli/cliui/prompt_test.go b/cli/cliui/prompt_test.go index 5ac0d906caae8..56f65a73fddd0 100644 --- a/cli/cliui/prompt_test.go +++ b/cli/cliui/prompt_test.go @@ -13,7 +13,6 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" - "github.com/coder/coder/v2/pty" "github.com/coder/coder/v2/pty/ptytest" "github.com/coder/coder/v2/testutil" "github.com/coder/serpent" @@ -181,6 +180,28 @@ func TestPrompt(t *testing.T) { resp := testutil.TryReceive(ctx, t, doneChan) require.Equal(t, "valid", resp) }) + + t.Run("MaskedSecret", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + ptty := ptytest.New(t) + doneChan := make(chan string) + go func() { + resp, err := newPrompt(ctx, ptty, cliui.PromptOptions{ + Text: "Password:", + Secret: true, + Mask: '*', + }, nil) + assert.NoError(t, err) + doneChan <- resp + }() + ptty.ExpectMatch("Password: ") + + ptty.WriteLine("test") + + resp := testutil.TryReceive(ctx, t, doneChan) + require.Equal(t, "test", resp) + }) } func newPrompt(ctx context.Context, ptty *ptytest.PTY, opts cliui.PromptOptions, invOpt func(inv *serpent.Invocation)) (string, error) { @@ -212,10 +233,6 @@ func TestPasswordTerminalState(t *testing.T) { t.Parallel() ptty := ptytest.New(t) - ptyWithFlags, ok := ptty.PTY.(pty.WithFlags) - if !ok { - t.Skip("unable to check PTY local echo on this platform") - } cmd := exec.Command(os.Args[0], "-test.run=TestPasswordTerminalState") //nolint:gosec cmd.Env = append(os.Environ(), "TEST_SUBPROCESS=1") @@ -229,21 +246,17 @@ func TestPasswordTerminalState(t *testing.T) { defer process.Kill() ptty.ExpectMatch("Password: ") - - require.Eventually(t, func() bool { - echo, err := ptyWithFlags.EchoEnabled() - return err == nil && !echo - }, testutil.WaitShort, testutil.IntervalMedium, "echo is on while reading password") + ptty.Write('t') + ptty.Write('e') + ptty.Write('s') + ptty.Write('t') + ptty.Write('\b') + ptty.ExpectMatch("***") err = process.Signal(os.Interrupt) require.NoError(t, err) _, err = process.Wait() require.NoError(t, err) - - require.Eventually(t, func() bool { - echo, err := ptyWithFlags.EchoEnabled() - return err == nil && echo - }, testutil.WaitShort, testutil.IntervalMedium, "echo is off after reading password") } // nolint:unused @@ -253,6 +266,7 @@ func passwordHelper() { cliui.Prompt(inv, cliui.PromptOptions{ Text: "Password:", Secret: true, + Mask: '*', }) return nil }, diff --git a/cli/login.go b/cli/login.go index fcba1ee50eb74..3a284248be078 100644 --- a/cli/login.go +++ b/cli/login.go @@ -356,6 +356,7 @@ func (r *RootCmd) login() *serpent.Command { sessionToken, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: "Paste your token here:", Secret: true, + Mask: '*', Validate: func(token string) error { client.SetSessionToken(token) _, err := client.User(ctx, codersdk.Me) diff --git a/cli/server_createadminuser.go b/cli/server_createadminuser.go index 40d65507dc087..48688a04be532 100644 --- a/cli/server_createadminuser.go +++ b/cli/server_createadminuser.go @@ -136,6 +136,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command { newUserPassword, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: "Password", Secret: true, + Mask: '*', Validate: func(val string) error { if val == "" { return xerrors.New("password cannot be empty") @@ -151,6 +152,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command { _, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: "Confirm password", Secret: true, + Mask: '*', Validate: func(val string) error { if val != newUserPassword { return xerrors.New("passwords do not match") diff --git a/cmd/cliui/main.go b/cmd/cliui/main.go index 6a363a3404618..0f778aa8e099c 100644 --- a/cmd/cliui/main.go +++ b/cmd/cliui/main.go @@ -109,6 +109,7 @@ func main() { _, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: "Enter password", Secret: true, + Mask: '*', }) return err }, diff --git a/go.mod b/go.mod index 11da4f20eb80d..ed1042b699334 100644 --- a/go.mod +++ b/go.mod @@ -83,7 +83,6 @@ require ( github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 github.com/awalterschulze/gographviz v2.0.3+incompatible github.com/aws/smithy-go v1.22.3 - github.com/bgentry/speakeasy v0.2.0 github.com/bramvdbogaerde/go-scp v1.5.0 github.com/briandowns/spinner v1.23.0 github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 diff --git a/go.sum b/go.sum index 4bb24abd6be6b..695812397c14d 100644 --- a/go.sum +++ b/go.sum @@ -815,8 +815,6 @@ github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bgentry/speakeasy v0.2.0 h1:tgObeVOf8WAvtuAX6DhJ4xks4CFNwPDZiqzGqIHE51E= -github.com/bgentry/speakeasy v0.2.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= From ea61493c569f0d31b5efd27fa92f860bb9defa56 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 18 Apr 2025 13:50:25 +0000 Subject: [PATCH 02/13] removed backspace test --- cli/cliui/prompt_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cli/cliui/prompt_test.go b/cli/cliui/prompt_test.go index 56f65a73fddd0..ad798d91d6d32 100644 --- a/cli/cliui/prompt_test.go +++ b/cli/cliui/prompt_test.go @@ -250,8 +250,7 @@ func TestPasswordTerminalState(t *testing.T) { ptty.Write('e') ptty.Write('s') ptty.Write('t') - ptty.Write('\b') - ptty.ExpectMatch("***") + ptty.ExpectMatch("****") err = process.Signal(os.Interrupt) require.NoError(t, err) From 25f53342f3d99c119e8adf169a2ea39478e05ab0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 18 Apr 2025 14:56:57 +0000 Subject: [PATCH 03/13] removed the test from windows --- cli/cliui/prompt_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cli/cliui/prompt_test.go b/cli/cliui/prompt_test.go index ad798d91d6d32..7705473fde1bc 100644 --- a/cli/cliui/prompt_test.go +++ b/cli/cliui/prompt_test.go @@ -6,6 +6,7 @@ import ( "io" "os" "os/exec" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -230,6 +231,9 @@ func TestPasswordTerminalState(t *testing.T) { passwordHelper() return } + if runtime.GOOS == "windows" { + t.Skip("Skipping on windows. PTY doesn't read ptty.Write correctly.") + } t.Parallel() ptty := ptytest.New(t) @@ -250,7 +254,8 @@ func TestPasswordTerminalState(t *testing.T) { ptty.Write('e') ptty.Write('s') ptty.Write('t') - ptty.ExpectMatch("****") + ptty.Write('\b') + ptty.ExpectMatch("***") err = process.Signal(os.Interrupt) require.NoError(t, err) From 586faf69308ddd9710cd6716326c78627069654c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 22 Apr 2025 12:05:06 +0000 Subject: [PATCH 04/13] PR feedback --- cli/cliui/prompt.go | 106 +++++++++++++++++----------------- cli/cliui/prompt_test.go | 2 - cli/login.go | 1 - cli/server_createadminuser.go | 2 - cmd/cliui/main.go | 1 - 5 files changed, 54 insertions(+), 58 deletions(-) diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index 25314c89192db..f6042b4652252 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -9,20 +9,21 @@ import ( "os/signal" "strings" - "github.com/mattn/go-isatty" - "golang.org/x/term" "golang.org/x/xerrors" + "github.com/mattn/go-isatty" + + "github.com/coder/coder/v2/pty" "github.com/coder/pretty" "github.com/coder/serpent" ) // PromptOptions supply a set of options to the prompt. type PromptOptions struct { - Text string - Default string + Text string + Default string + // When true, the input will be masked with asterisks. Secret bool - Mask rune IsConfirm bool Validate func(string) error } @@ -91,53 +92,7 @@ func Prompt(inv *serpent.Invocation, opts PromptOptions) (string, error) { inFile, isInputFile := inv.Stdin.(*os.File) if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) { - // Set terminal to raw mode - oldState, err := term.MakeRaw(int(inFile.Fd())) - if err != nil { - errCh <- err - return - } - defer func() { - _ = term.Restore(int(inFile.Fd()), oldState) - }() - - // Read input character by character - buf := make([]byte, 1) - for { - n, err := inv.Stdin.Read(buf) - if err != nil || n == 0 { - break - } - - // Handle special characters - switch buf[0] { - case '\r', '\n': // Enter - _, _ = fmt.Fprint(inv.Stdout, "\n") - lineCh <- line - return - case 3: // Ctrl+C - _, _ = fmt.Fprint(inv.Stdout, "\n") - errCh <- ErrCanceled - return - case 8, 127: // Backspace/Delete - if len(line) > 0 { - line = line[:len(line)-1] - // Move cursor back, print space, move cursor back again - _, _ = fmt.Fprint(inv.Stdout, "\b \b") - } - default: - // Only append printable characters - if buf[0] >= 32 && buf[0] <= 126 { - line += string(buf[0]) - // Print the mask character - if opts.Mask == 0 { - _, _ = fmt.Fprint(inv.Stdout, "") - } else { - _, _ = fmt.Fprint(inv.Stdout, string(opts.Mask)) - } - } - } - } + line, err = readSecretInput(inFile) } else { signal.Notify(interrupt, os.Interrupt) defer signal.Stop(interrupt) @@ -250,3 +205,50 @@ func readUntil(r io.Reader, delim byte) (string, error) { } } } + +// readSecretInput reads secret input from the terminal character by character, +// masking the input with asterisks. It handles special characters like backspace +// and enter appropriately. +func readSecretInput(f *os.File) (string, error) { + // Set terminal to raw mode + oldState, err := pty.MakeInputRaw(f.Fd()) + if err != nil { + return "", err + } + defer func() { + _ = pty.RestoreTerminal(f.Fd(), oldState) + }() + + // Read input character by character + buf := make([]byte, 1) + var line string + for { + n, err := f.Read(buf) + if err != nil || n == 0 { + return "", ErrCanceled + } + + // Handle special characters + switch buf[0] { + case '\r', '\n': // Enter + _, _ = f.WriteString("\n") + return line, nil + case 3: // Ctrl+C + _, _ = f.WriteString("\n") + return "", ErrCanceled + case 8, 127: // Backspace/Delete + if len(line) > 0 { + line = line[:len(line)-1] + // Move cursor back, print space, move cursor back again + _, _ = f.WriteString("\b \b") + } + default: + // Only append printable characters + if buf[0] >= 32 && buf[0] <= 126 { + line += string(buf[0]) + // Print the mask character + _, _ = f.WriteString("*") + } + } + } +} diff --git a/cli/cliui/prompt_test.go b/cli/cliui/prompt_test.go index 7705473fde1bc..52e803da41404 100644 --- a/cli/cliui/prompt_test.go +++ b/cli/cliui/prompt_test.go @@ -191,7 +191,6 @@ func TestPrompt(t *testing.T) { resp, err := newPrompt(ctx, ptty, cliui.PromptOptions{ Text: "Password:", Secret: true, - Mask: '*', }, nil) assert.NoError(t, err) doneChan <- resp @@ -270,7 +269,6 @@ func passwordHelper() { cliui.Prompt(inv, cliui.PromptOptions{ Text: "Password:", Secret: true, - Mask: '*', }) return nil }, diff --git a/cli/login.go b/cli/login.go index 3a284248be078..fcba1ee50eb74 100644 --- a/cli/login.go +++ b/cli/login.go @@ -356,7 +356,6 @@ func (r *RootCmd) login() *serpent.Command { sessionToken, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: "Paste your token here:", Secret: true, - Mask: '*', Validate: func(token string) error { client.SetSessionToken(token) _, err := client.User(ctx, codersdk.Me) diff --git a/cli/server_createadminuser.go b/cli/server_createadminuser.go index 48688a04be532..40d65507dc087 100644 --- a/cli/server_createadminuser.go +++ b/cli/server_createadminuser.go @@ -136,7 +136,6 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command { newUserPassword, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: "Password", Secret: true, - Mask: '*', Validate: func(val string) error { if val == "" { return xerrors.New("password cannot be empty") @@ -152,7 +151,6 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command { _, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: "Confirm password", Secret: true, - Mask: '*', Validate: func(val string) error { if val != newUserPassword { return xerrors.New("passwords do not match") diff --git a/cmd/cliui/main.go b/cmd/cliui/main.go index 0f778aa8e099c..6a363a3404618 100644 --- a/cmd/cliui/main.go +++ b/cmd/cliui/main.go @@ -109,7 +109,6 @@ func main() { _, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: "Enter password", Secret: true, - Mask: '*', }) return err }, From b23e5cca87c3f67e821c0c7b944c8ee620129f79 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 22 Apr 2025 12:31:16 +0000 Subject: [PATCH 05/13] windows writer handling --- cli/cliui/prompt.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index f6042b4652252..701d81904625b 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -231,23 +231,23 @@ func readSecretInput(f *os.File) (string, error) { // Handle special characters switch buf[0] { case '\r', '\n': // Enter - _, _ = f.WriteString("\n") + _, _ = f.Write([]byte("\n")) return line, nil case 3: // Ctrl+C - _, _ = f.WriteString("\n") + _, _ = f.Write([]byte("\n")) return "", ErrCanceled case 8, 127: // Backspace/Delete if len(line) > 0 { line = line[:len(line)-1] // Move cursor back, print space, move cursor back again - _, _ = f.WriteString("\b \b") + _, _ = f.Write([]byte("\b \b")) } default: // Only append printable characters if buf[0] >= 32 && buf[0] <= 126 { line += string(buf[0]) // Print the mask character - _, _ = f.WriteString("*") + _, _ = f.Write([]byte("*")) } } } From 7d956d35dae26ede376b4bdb2d0600d970e603ab Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 22 Apr 2025 12:49:49 +0000 Subject: [PATCH 06/13] windows writer handling --- cli/cliui/prompt.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index 701d81904625b..d8b1647d9e059 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -92,7 +92,7 @@ func Prompt(inv *serpent.Invocation, opts PromptOptions) (string, error) { inFile, isInputFile := inv.Stdin.(*os.File) if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) { - line, err = readSecretInput(inFile) + line, err = readSecretInput(inFile, inv.Stdout) } else { signal.Notify(interrupt, os.Interrupt) defer signal.Stop(interrupt) @@ -209,7 +209,7 @@ func readUntil(r io.Reader, delim byte) (string, error) { // readSecretInput reads secret input from the terminal character by character, // masking the input with asterisks. It handles special characters like backspace // and enter appropriately. -func readSecretInput(f *os.File) (string, error) { +func readSecretInput(f *os.File, w io.Writer) (string, error) { // Set terminal to raw mode oldState, err := pty.MakeInputRaw(f.Fd()) if err != nil { @@ -231,23 +231,23 @@ func readSecretInput(f *os.File) (string, error) { // Handle special characters switch buf[0] { case '\r', '\n': // Enter - _, _ = f.Write([]byte("\n")) + _, _ = w.Write([]byte("\n")) return line, nil case 3: // Ctrl+C - _, _ = f.Write([]byte("\n")) + _, _ = w.Write([]byte("\n")) return "", ErrCanceled case 8, 127: // Backspace/Delete if len(line) > 0 { line = line[:len(line)-1] // Move cursor back, print space, move cursor back again - _, _ = f.Write([]byte("\b \b")) + _, _ = w.Write([]byte("\b \b")) } default: // Only append printable characters if buf[0] >= 32 && buf[0] <= 126 { line += string(buf[0]) // Print the mask character - _, _ = f.Write([]byte("*")) + _, _ = w.Write([]byte("*")) } } } From 8f67a4a0e954eb2ec67bca4bce342ea726fb0782 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 22 Apr 2025 12:57:54 +0000 Subject: [PATCH 07/13] last windows writer handling fix --- cli/cliui/prompt.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index d8b1647d9e059..4d8275e8ce6e7 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -210,7 +210,7 @@ func readUntil(r io.Reader, delim byte) (string, error) { // masking the input with asterisks. It handles special characters like backspace // and enter appropriately. func readSecretInput(f *os.File, w io.Writer) (string, error) { - // Set terminal to raw mode + // Set terminal to raw mode to capture input character by character oldState, err := pty.MakeInputRaw(f.Fd()) if err != nil { return "", err @@ -231,10 +231,10 @@ func readSecretInput(f *os.File, w io.Writer) (string, error) { // Handle special characters switch buf[0] { case '\r', '\n': // Enter - _, _ = w.Write([]byte("\n")) + _, _ = w.Write([]byte("\r\n")) return line, nil case 3: // Ctrl+C - _, _ = w.Write([]byte("\n")) + _, _ = w.Write([]byte("\r\n")) return "", ErrCanceled case 8, 127: // Backspace/Delete if len(line) > 0 { From 8d6de7b47e28bc0093ee96c2aefc1f6ef9d4ae3e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 22 Apr 2025 17:56:21 +0000 Subject: [PATCH 08/13] removed sanity check on entered characters --- cli/cliui/prompt.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index 4d8275e8ce6e7..a9e0360036f92 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -243,12 +243,9 @@ func readSecretInput(f *os.File, w io.Writer) (string, error) { _, _ = w.Write([]byte("\b \b")) } default: - // Only append printable characters - if buf[0] >= 32 && buf[0] <= 126 { - line += string(buf[0]) - // Print the mask character - _, _ = w.Write([]byte("*")) - } + line += string(buf[0]) + // Print the mask character + _, _ = w.Write([]byte("*")) } } } From 2607ed0feefa68e01e3b0607c84082a398c3150c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Tue, 22 Apr 2025 18:00:09 +0000 Subject: [PATCH 09/13] import order --- cli/cliui/prompt.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index a9e0360036f92..eaf0d0f5f05b6 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -9,9 +9,8 @@ import ( "os/signal" "strings" - "golang.org/x/xerrors" - "github.com/mattn/go-isatty" + "golang.org/x/xerrors" "github.com/coder/coder/v2/pty" "github.com/coder/pretty" From 2042912f3729a86161b8275bc6f121b599388d38 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 23 Apr 2025 12:40:07 +0000 Subject: [PATCH 10/13] simplified the logic and moved to byte handling to respect UTF-8 --- cli/cliui/prompt.go | 38 +++++++++++++++----------------------- cli/cliui/prompt_test.go | 10 ++++------ 2 files changed, 19 insertions(+), 29 deletions(-) diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index eaf0d0f5f05b6..0331a23baeb96 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -91,6 +91,8 @@ func Prompt(inv *serpent.Invocation, opts PromptOptions) (string, error) { inFile, isInputFile := inv.Stdin.(*os.File) if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) { + signal.Notify(interrupt, os.Interrupt) + defer signal.Stop(interrupt) line, err = readSecretInput(inFile, inv.Stdout) } else { signal.Notify(interrupt, os.Interrupt) @@ -206,8 +208,7 @@ func readUntil(r io.Reader, delim byte) (string, error) { } // readSecretInput reads secret input from the terminal character by character, -// masking the input with asterisks. It handles special characters like backspace -// and enter appropriately. +// masking the input with asterisks. func readSecretInput(f *os.File, w io.Writer) (string, error) { // Set terminal to raw mode to capture input character by character oldState, err := pty.MakeInputRaw(f.Fd()) @@ -217,34 +218,25 @@ func readSecretInput(f *os.File, w io.Writer) (string, error) { defer func() { _ = pty.RestoreTerminal(f.Fd(), oldState) }() + var valb []byte // Read input character by character buf := make([]byte, 1) - var line string for { n, err := f.Read(buf) - if err != nil || n == 0 { - return "", ErrCanceled - } - // Handle special characters - switch buf[0] { - case '\r', '\n': // Enter - _, _ = w.Write([]byte("\r\n")) - return line, nil - case 3: // Ctrl+C - _, _ = w.Write([]byte("\r\n")) + if err != nil && err != io.EOF { + return "", err + } + if n == 0 || buf[0] == '\n' || buf[0] == '\r' { + break + } + if buf[0] == 3 { return "", ErrCanceled - case 8, 127: // Backspace/Delete - if len(line) > 0 { - line = line[:len(line)-1] - // Move cursor back, print space, move cursor back again - _, _ = w.Write([]byte("\b \b")) - } - default: - line += string(buf[0]) - // Print the mask character - _, _ = w.Write([]byte("*")) } + valb = append(valb, buf[0]) + _, _ = w.Write([]byte("*")) } + _, _ = w.Write([]byte("\r\n")) + return strings.TrimSuffix(string(valb), "\r"), nil } diff --git a/cli/cliui/prompt_test.go b/cli/cliui/prompt_test.go index 52e803da41404..ce931ef573d3c 100644 --- a/cli/cliui/prompt_test.go +++ b/cli/cliui/prompt_test.go @@ -6,7 +6,6 @@ import ( "io" "os" "os/exec" - "runtime" "testing" "github.com/stretchr/testify/assert" @@ -230,9 +229,9 @@ func TestPasswordTerminalState(t *testing.T) { passwordHelper() return } - if runtime.GOOS == "windows" { - t.Skip("Skipping on windows. PTY doesn't read ptty.Write correctly.") - } + // if runtime.GOOS == "windows" { + // t.Skip("Skipping on windows. PTY doesn't read ptty.Write correctly.") + // } t.Parallel() ptty := ptytest.New(t) @@ -253,8 +252,7 @@ func TestPasswordTerminalState(t *testing.T) { ptty.Write('e') ptty.Write('s') ptty.Write('t') - ptty.Write('\b') - ptty.ExpectMatch("***") + ptty.ExpectMatch("****") err = process.Signal(os.Interrupt) require.NoError(t, err) From c7d7d21560720dfa0d48254e03ab3e01962960c1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 23 Apr 2025 13:12:24 +0000 Subject: [PATCH 11/13] switched the implementation to use runes --- cli/cliui/prompt.go | 57 +++++++++++++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index 0331a23baeb96..01d10cb807661 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -1,6 +1,7 @@ package cliui import ( + "bufio" "bytes" "encoding/json" "fmt" @@ -8,6 +9,7 @@ import ( "os" "os/signal" "strings" + "unicode" "github.com/mattn/go-isatty" "golang.org/x/xerrors" @@ -207,10 +209,10 @@ func readUntil(r io.Reader, delim byte) (string, error) { } } -// readSecretInput reads secret input from the terminal character by character, -// masking the input with asterisks. +// readSecretInput reads secret input from the terminal rune-by-rune, +// masking each character with an asterisk. func readSecretInput(f *os.File, w io.Writer) (string, error) { - // Set terminal to raw mode to capture input character by character + // Put terminal into raw mode (no echo, no line buffering). oldState, err := pty.MakeInputRaw(f.Fd()) if err != nil { return "", err @@ -218,25 +220,46 @@ func readSecretInput(f *os.File, w io.Writer) (string, error) { defer func() { _ = pty.RestoreTerminal(f.Fd(), oldState) }() - var valb []byte - // Read input character by character - buf := make([]byte, 1) - for { - n, err := f.Read(buf) + reader := bufio.NewReader(f) + var runes []rune - if err != nil && err != io.EOF { + for { + r, _, err := reader.ReadRune() + if err != nil { return "", err } - if n == 0 || buf[0] == '\n' || buf[0] == '\r' { - break - } - if buf[0] == 3 { + + switch { + case r == '\r' || r == '\n': + // Finish on Enter + if _, err := fmt.Fprint(w, "\r\n"); err != nil { + return "", err + } + return string(runes), nil + + case r == 3: + // Ctrl+C return "", ErrCanceled + + case r == 127 || r == '\b': + // Backspace/Delete: remove last rune + if len(runes) > 0 { + // Erase the last '*' on the screen + if _, err := fmt.Fprint(w, "\b \b"); err != nil { + return "", err + } + runes = runes[:len(runes)-1] + } + + default: + // Only mask printable, non-control runes + if !unicode.IsControl(r) { + runes = append(runes, r) + if _, err := fmt.Fprint(w, "*"); err != nil { + return "", err + } + } } - valb = append(valb, buf[0]) - _, _ = w.Write([]byte("*")) } - _, _ = w.Write([]byte("\r\n")) - return strings.TrimSuffix(string(valb), "\r"), nil } From d8a58302049f93018bdcf8ca708754c2ca6a3ee3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 23 Apr 2025 13:27:09 +0000 Subject: [PATCH 12/13] added back the windows failing test --- cli/cliui/prompt_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cli/cliui/prompt_test.go b/cli/cliui/prompt_test.go index ce931ef573d3c..a5ab0531acdc4 100644 --- a/cli/cliui/prompt_test.go +++ b/cli/cliui/prompt_test.go @@ -6,6 +6,7 @@ import ( "io" "os" "os/exec" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -229,9 +230,9 @@ func TestPasswordTerminalState(t *testing.T) { passwordHelper() return } - // if runtime.GOOS == "windows" { - // t.Skip("Skipping on windows. PTY doesn't read ptty.Write correctly.") - // } + if runtime.GOOS == "windows" { + t.Skip("Skipping on windows. PTY doesn't read ptty.Write correctly.") + } t.Parallel() ptty := ptytest.New(t) From f9bd0bfb206276332043e6d41a15eb797e888eac Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 24 Apr 2025 07:44:11 +0000 Subject: [PATCH 13/13] added test for UTF-8 characters and simplified the signal interrupt handling --- cli/cliui/prompt.go | 8 +++----- cli/cliui/prompt_test.go | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/cli/cliui/prompt.go b/cli/cliui/prompt.go index 01d10cb807661..264ebf2939780 100644 --- a/cli/cliui/prompt.go +++ b/cli/cliui/prompt.go @@ -91,15 +91,13 @@ func Prompt(inv *serpent.Invocation, opts PromptOptions) (string, error) { var line string var err error + signal.Notify(interrupt, os.Interrupt) + defer signal.Stop(interrupt) + inFile, isInputFile := inv.Stdin.(*os.File) if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) { - signal.Notify(interrupt, os.Interrupt) - defer signal.Stop(interrupt) line, err = readSecretInput(inFile, inv.Stdout) } else { - signal.Notify(interrupt, os.Interrupt) - defer signal.Stop(interrupt) - line, err = readUntil(inv.Stdin, '\n') // Check if the first line beings with JSON object or array chars. diff --git a/cli/cliui/prompt_test.go b/cli/cliui/prompt_test.go index a5ab0531acdc4..8b5a3e98ea1f7 100644 --- a/cli/cliui/prompt_test.go +++ b/cli/cliui/prompt_test.go @@ -202,6 +202,27 @@ func TestPrompt(t *testing.T) { resp := testutil.TryReceive(ctx, t, doneChan) require.Equal(t, "test", resp) }) + + t.Run("UTF8Password", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + ptty := ptytest.New(t) + doneChan := make(chan string) + go func() { + resp, err := newPrompt(ctx, ptty, cliui.PromptOptions{ + Text: "Password:", + Secret: true, + }, nil) + assert.NoError(t, err) + doneChan <- resp + }() + ptty.ExpectMatch("Password: ") + + ptty.WriteLine("和製漢字") + + resp := testutil.TryReceive(ctx, t, doneChan) + require.Equal(t, "和製漢字", resp) + }) } func newPrompt(ctx context.Context, ptty *ptytest.PTY, opts cliui.PromptOptions, invOpt func(inv *serpent.Invocation)) (string, error) {
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: