Skip to content
This repository was archived by the owner on Feb 16, 2023. It is now read-only.

Commit 2736428

Browse files
committed
Refactor ui package to not wrap os.Stdout and os.Stdin and allow easier mocking
1 parent e972b86 commit 2736428

13 files changed

Lines changed: 144 additions & 97 deletions

File tree

internals/cli/ui/ask.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ func AskWithDefault(io IO, question, defaultValue string) (string, error) {
5151

5252
// AskSecret prints out the question and reads back the input,
5353
// without echoing it back. Useful for passwords and other sensitive inputs.
54-
func AskSecret(io IO, question string) (string, error) {
54+
func AskSecret(io IO, passwordReader PasswordReader, question string) (string, error) {
5555
promptIn, promptOut, err := io.Prompts()
5656
if err != nil {
5757
return "", err
@@ -62,14 +62,14 @@ func AskSecret(io IO, question string) (string, error) {
6262
return "", err
6363
}
6464

65-
raw, err := promptIn.ReadPassword()
65+
raw, err := passwordReader.Read(promptIn)
6666
if err != nil {
6767
return "", ErrReadInput(err)
6868
}
6969

7070
fmt.Fprintln(promptOut, "")
7171

72-
return string(raw), nil
72+
return raw, nil
7373
}
7474

7575
// AskMultiline prints out the question and reads back the input until an EOF is reached.
@@ -145,14 +145,14 @@ func ConfirmCaseInsensitive(io IO, question string, expected ...string) (bool, e
145145
// the answers still haven't matched after trying n times, the error
146146
// ErrPassphrasesDoNotMatch is returned. For the empty answer ("") no
147147
// confirmation is asked.
148-
func AskPassphrase(io IO, question string, repeatPhrase string, n int) (string, error) {
148+
func AskPassphrase(io IO, passwordReader PasswordReader, question string, repeatPhrase string, n int) (string, error) {
149149
_, promptOut, err := io.Prompts()
150150
if err != nil {
151151
return "", err
152152
}
153153

154154
for i := 0; i < n; i++ {
155-
answer, err := AskSecret(io, question)
155+
answer, err := AskSecret(io, passwordReader, question)
156156
if err != nil {
157157
return "", err
158158
}
@@ -161,7 +161,7 @@ func AskPassphrase(io IO, question string, repeatPhrase string, n int) (string,
161161
return answer, nil
162162
}
163163

164-
confirmed, err := AskSecret(io, repeatPhrase)
164+
confirmed, err := AskSecret(io, passwordReader, repeatPhrase)
165165
if err != nil {
166166
return "", err
167167
}

internals/cli/ui/io.go

Lines changed: 52 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -18,35 +18,37 @@ var (
1818

1919
// IO is an interface to work with input/output.
2020
type IO interface {
21-
Stdin() Reader
22-
Stdout() Writer
23-
Prompts() (Reader, Writer, error)
21+
Stdin() io.Reader
22+
Stdout() io.Writer
23+
Prompts() (io.Reader, io.Writer, error)
24+
IsStdinPiped() bool
25+
IsStdoutPiped() bool
2426
}
2527

2628
// UserIO is a middleware between input and output to the CLI program.
2729
// It implements userIO.Prompter and can be passed to libraries.
2830
type UserIO struct {
29-
Input Reader
30-
Output Writer
31-
tty file
31+
Input *os.File
32+
Output *os.File
33+
tty *os.File
3234
ttyAvailable bool
3335
}
3436

3537
// NewStdUserIO creates a new UserIO middleware only from os.Stdin and os.Stdout.
3638
func NewStdUserIO() UserIO {
3739
return UserIO{
38-
Input: file{os.Stdin},
39-
Output: file{os.Stdout},
40+
Input: os.Stdin,
41+
Output: os.Stdout,
4042
}
4143
}
4244

4345
// Stdin returns the UserIO's Input.
44-
func (o UserIO) Stdin() Reader {
46+
func (o UserIO) Stdin() io.Reader {
4547
return o.Input
4648
}
4749

4850
// Stdout returns the UserIO's Output.
49-
func (o UserIO) Stdout() Writer {
51+
func (o UserIO) Stdout() io.Writer {
5052
return o.Output
5153
}
5254

@@ -55,8 +57,8 @@ func (o UserIO) Stdout() Writer {
5557
// bypass stdin and stdout by connecting to /dev/tty on Unix systems when
5658
// available. On systems where tty is not available and when either input
5759
// or output is piped, prompting is not possible so an error is returned.
58-
func (o UserIO) Prompts() (Reader, Writer, error) {
59-
if o.Input.IsPiped() || o.Output.IsPiped() {
60+
func (o UserIO) Prompts() (io.Reader, io.Writer, error) {
61+
if o.IsStdoutPiped() || o.IsStdinPiped() {
6062
if o.ttyAvailable {
6163
return o.tty, o.tty, nil
6264
}
@@ -65,50 +67,58 @@ func (o UserIO) Prompts() (Reader, Writer, error) {
6567
return o.Input, o.Output, nil
6668
}
6769

68-
// Reader can read input for a CLI program.
69-
type Reader interface {
70-
io.Reader
71-
// ReadPassword reads a line of input from a terminal without local echo.
72-
ReadPassword() ([]byte, error)
73-
IsPiped() bool
70+
func (o UserIO) IsStdinPiped() bool {
71+
return isPiped(o.Input)
7472
}
7573

76-
// Readln reads 1 line of input from a io.Reader. The newline character is not included in the response.
77-
func Readln(r io.Reader) (string, error) {
78-
s := bufio.NewScanner(r)
79-
s.Scan()
80-
err := s.Err()
81-
if err != nil {
82-
return "", ErrReadInput(err)
83-
}
84-
return s.Text(), nil
74+
func (o UserIO) IsStdoutPiped() bool {
75+
return isPiped(o.Output)
8576
}
8677

87-
// Writer can write output for a CLI program.
88-
type Writer interface {
89-
io.Writer
90-
IsPiped() bool
78+
type PasswordReader interface {
79+
Read(reader io.Reader) (string, error)
9180
}
9281

93-
// file implements the Reader and Writer interface.
94-
type file struct {
95-
*os.File
82+
type passwordReader struct{}
83+
84+
// NewPasswordReader returns a reader that reads a string from the terminal without echoing the user input.
85+
func NewPasswordReader() *passwordReader {
86+
return &passwordReader{}
9687
}
9788

98-
// ReadPassword reads from a terminal without echoing back the typed input.
99-
func (f file) ReadPassword() ([]byte, error) {
89+
// Read reads one line of input from the terminal without echoing the user input.
90+
func (pr *passwordReader) Read(r io.Reader) (string, error) {
91+
file, ok := r.(*os.File)
92+
if !ok {
93+
return "", ErrCannotAsk
94+
}
10095
// this case happens among other things when input is piped and ReadPassword is called.
101-
if !terminal.IsTerminal(int(f.Fd())) {
102-
return nil, ErrCannotAsk
96+
if !terminal.IsTerminal(int(file.Fd())) {
97+
return "", ErrCannotAsk
98+
}
99+
100+
password, err := terminal.ReadPassword(int(file.Fd()))
101+
if err != nil {
102+
return "", err
103103
}
104+
return string(password), nil
105+
}
104106

105-
return terminal.ReadPassword(int(f.Fd()))
107+
// Readln reads 1 line of input from a io.Reader. The newline character is not included in the response.
108+
func Readln(r io.Reader) (string, error) {
109+
s := bufio.NewScanner(r)
110+
s.Scan()
111+
err := s.Err()
112+
if err != nil {
113+
return "", ErrReadInput(err)
114+
}
115+
return s.Text(), nil
106116
}
107117

108-
// IsPiped checks whether the file is a pipe.
118+
// isPiped checks whether the file is a pipe.
109119
// If the file does not exist, it returns false.
110-
func (f file) IsPiped() bool {
111-
stat, err := f.Stat()
120+
func isPiped(file *os.File) bool {
121+
stat, err := file.Stat()
112122
if err != nil {
113123
return false
114124
}

internals/cli/ui/io_unix.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ func NewUserIO() UserIO {
99
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
1010
if err == nil {
1111
return UserIO{
12-
Input: file{os.Stdin},
13-
Output: file{os.Stdout},
14-
tty: file{tty},
12+
Input: os.Stdin,
13+
Output: os.Stdout,
14+
tty: tty,
1515
ttyAvailable: true,
1616
}
1717
}

internals/cli/ui/testing.go

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
package ui
44

55
import (
6+
"bufio"
67
"bytes"
78
"errors"
9+
"io"
810
)
911

1012
// FakeIO is a helper type for testing that implements the ui.IO interface
@@ -35,20 +37,33 @@ func NewFakeIO() *FakeIO {
3537
}
3638

3739
// Stdin returns the mocked StdIn.
38-
func (f *FakeIO) Stdin() Reader {
40+
func (f *FakeIO) Stdin() io.Reader {
3941
return f.StdIn
4042
}
4143

4244
// Stdout returns the mocked StdOut.
43-
func (f *FakeIO) Stdout() Writer {
45+
func (f *FakeIO) Stdout() io.Writer {
4446
return f.StdOut
4547
}
4648

4749
// Prompts returns the mocked prompts and error.
48-
func (f *FakeIO) Prompts() (Reader, Writer, error) {
50+
func (f *FakeIO) Prompts() (io.Reader, io.Writer, error) {
4951
return f.PromptIn, f.PromptOut, f.PromptErr
5052
}
5153

54+
func (f *FakeIO) IsStdinPiped() bool {
55+
return f.StdIn.Piped
56+
}
57+
58+
func (f *FakeIO) IsStdoutPiped() bool {
59+
return f.StdOut.Piped
60+
}
61+
62+
func (f *FakeIO) ReadPassword() ([]byte, error) {
63+
line, _, err := bufio.NewReader(f.PromptIn).ReadLine()
64+
return line, err
65+
}
66+
5267
// FakeReader implements the Reader interface.
5368
type FakeReader struct {
5469
*bytes.Buffer
@@ -97,3 +112,9 @@ type FakeWriter struct {
97112
func (f *FakeWriter) IsPiped() bool {
98113
return f.Piped
99114
}
115+
116+
type FakePasswordReader struct{}
117+
118+
func (f FakePasswordReader) Read(reader io.Reader) (string, error) {
119+
return Readln(reader)
120+
}

internals/secrethub/account_init.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ var (
3636
// AccountInitCommand creates, stores and outputs a credential.
3737
type AccountInitCommand struct {
3838
io ui.IO
39+
passwordReader ui.PasswordReader
3940
useClipboard bool
4041
noWait bool
4142
isContinue bool
@@ -50,6 +51,7 @@ type AccountInitCommand struct {
5051
func NewAccountInitCommand(io ui.IO, newClient newClientFunc, credentialStore CredentialConfig) *AccountInitCommand {
5152
return &AccountInitCommand{
5253
io: io,
54+
passwordReader: ui.NewPasswordReader(),
5355
credentialStore: credentialStore,
5456
clipper: clip.NewClipboard(),
5557
progressPrinter: progress.NewPrinter(io.Stdout(), 500*time.Millisecond),
@@ -160,7 +162,7 @@ func (cmd *AccountInitCommand) Run() error {
160162
var passphrase string
161163
if !cmd.credentialStore.IsPassphraseSet() && !cmd.force {
162164
var err error
163-
passphrase, err = ui.AskPassphrase(cmd.io, "Please enter a passphrase to protect your local credential (leave empty for no passphrase): ", "Enter the same passphrase again: ", 3)
165+
passphrase, err = ui.AskPassphrase(cmd.io, cmd.passwordReader, "Please enter a passphrase to protect your local credential (leave empty for no passphrase): ", "Enter the same passphrase again: ", 3)
164166
if err != nil {
165167
return err
166168
}

internals/secrethub/config_update_passphrase.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ import (
1111

1212
type ConfigUpdatePassphraseCommand struct {
1313
io ui.IO
14+
passwordReader ui.PasswordReader
1415
credentialStore CredentialConfig
1516
}
1617

1718
// NewConfigUpdatePassphraseCommand creates a new ConfigUpdatePassphraseCommand.
1819
func NewConfigUpdatePassphraseCommand(io ui.IO, credentialStore CredentialConfig) *ConfigUpdatePassphraseCommand {
1920
return &ConfigUpdatePassphraseCommand{
2021
io: io,
22+
passwordReader: ui.NewPasswordReader(),
2123
credentialStore: credentialStore,
2224
}
2325
}
@@ -58,7 +60,7 @@ func (cmd *ConfigUpdatePassphraseCommand) Run() error {
5860
return err
5961
}
6062

61-
passphrase, err := ui.AskPassphrase(cmd.io, "Please enter a passphrase to (re)encrypt your local credential (leave empty for no passphrase): ", "Enter the same passphrase again: ", 3)
63+
passphrase, err := ui.AskPassphrase(cmd.io, cmd.passwordReader, "Please enter a passphrase to (re)encrypt your local credential (leave empty for no passphrase): ", "Enter the same passphrase again: ", 3)
6264
if err != nil {
6365
return err
6466
}

internals/secrethub/init.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type InitCommand struct {
1919
backupCode string
2020
force bool
2121
io ui.IO
22+
passwordReader ui.PasswordReader
2223
newClient newClientFunc
2324
newClientWithoutCredentials func(credentials.Provider) (secrethub.ClientInterface, error)
2425
credentialStore CredentialConfig
@@ -29,6 +30,7 @@ type InitCommand struct {
2930
func NewInitCommand(io ui.IO, newClient newClientFunc, newClientWithoutCredentials func(credentials.Provider) (secrethub.ClientInterface, error), credentialStore CredentialConfig) *InitCommand {
3031
return &InitCommand{
3132
io: io,
33+
passwordReader: ui.NewPasswordReader(),
3234
newClient: newClient,
3335
newClientWithoutCredentials: newClientWithoutCredentials,
3436
credentialStore: credentialStore,
@@ -167,7 +169,7 @@ func (cmd *InitCommand) Run() error {
167169
var passphrase string
168170
if !cmd.credentialStore.IsPassphraseSet() && !cmd.force {
169171
var err error
170-
passphrase, err = ui.AskPassphrase(cmd.io, "Please enter a passphrase to protect your local credential (leave empty for no passphrase): ", "Enter the same passphrase again: ", 3)
172+
passphrase, err = ui.AskPassphrase(cmd.io, cmd.passwordReader, "Please enter a passphrase to protect your local credential (leave empty for no passphrase): ", "Enter the same passphrase again: ", 3)
171173
if err != nil {
172174
return err
173175
}

internals/secrethub/inject.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func (cmd *InjectCommand) Run() error {
9191
return ErrReadFile(cmd.inFile, err)
9292
}
9393
} else {
94-
if !cmd.io.Stdin().IsPiped() {
94+
if !cmd.io.IsStdinPiped() {
9595
return ErrNoDataOnStdin
9696
}
9797

@@ -139,7 +139,7 @@ func (cmd *InjectCommand) Run() error {
139139
} else if cmd.outFile != "" {
140140
_, err := os.Stat(cmd.outFile)
141141
if err == nil && !cmd.force {
142-
if cmd.io.Stdout().IsPiped() {
142+
if cmd.io.IsStdoutPiped() {
143143
return ErrFileAlreadyExists
144144
}
145145

0 commit comments

Comments
 (0)