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) {