diff --git a/cmd/age/age.go b/cmd/age/age.go index e5d17e2b..a4397350 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -7,6 +7,7 @@ package main import ( "bufio" "bytes" + "filippo.io/age/tui" "flag" "fmt" "io" @@ -104,7 +105,7 @@ func main() { if len(os.Args) == 1 { flag.Usage() - exit(1) + tui.Exit(1) } var ( @@ -180,47 +181,47 @@ func main() { hints = append(hints, "only a single input file may be specified at a time") } - errorWithHint("too many INPUT arguments: "+quotedArgs, hints...) + tui.ErrorWithHint("too many INPUT arguments: "+quotedArgs, hints...) } switch { case decryptFlag: if encryptFlag { - errorf("-e/--encrypt can't be used with -d/--decrypt") + tui.Errorf("-e/--encrypt can't be used with -d/--decrypt") } if armorFlag { - errorWithHint("-a/--armor can't be used with -d/--decrypt", + tui.ErrorWithHint("-a/--armor can't be used with -d/--decrypt", "note that armored files are detected automatically") } if passFlag { - errorWithHint("-p/--passphrase can't be used with -d/--decrypt", + tui.ErrorWithHint("-p/--passphrase can't be used with -d/--decrypt", "note that password protected files are detected automatically") } if len(recipientFlags) > 0 { - errorWithHint("-r/--recipient can't be used with -d/--decrypt", + tui.ErrorWithHint("-r/--recipient can't be used with -d/--decrypt", "did you mean to use -i/--identity to specify a private key?") } if len(recipientsFileFlags) > 0 { - errorWithHint("-R/--recipients-file can't be used with -d/--decrypt", + tui.ErrorWithHint("-R/--recipients-file can't be used with -d/--decrypt", "did you mean to use -i/--identity to specify a private key?") } default: // encrypt if len(identityFlags) > 0 && !encryptFlag { - errorWithHint("-i/--identity and -j can't be used in encryption mode unless symmetric encryption is explicitly selected with -e/--encrypt", + tui.ErrorWithHint("-i/--identity and -j can't be used in encryption mode unless symmetric encryption is explicitly selected with -e/--encrypt", "did you forget to specify -d/--decrypt?") } if len(recipientFlags)+len(recipientsFileFlags)+len(identityFlags) == 0 && !passFlag { - errorWithHint("missing recipients", + tui.ErrorWithHint("missing recipients", "did you forget to specify -r/--recipient, -R/--recipients-file or -p/--passphrase?") } if len(recipientFlags) > 0 && passFlag { - errorf("-p/--passphrase can't be combined with -r/--recipient") + tui.Errorf("-p/--passphrase can't be combined with -r/--recipient") } if len(recipientsFileFlags) > 0 && passFlag { - errorf("-p/--passphrase can't be combined with -R/--recipients-file") + tui.Errorf("-p/--passphrase can't be combined with -R/--recipients-file") } if len(identityFlags) > 0 && passFlag { - errorf("-p/--passphrase can't be combined with -i/--identity and -j") + tui.Errorf("-p/--passphrase can't be combined with -i/--identity and -j") } } @@ -241,7 +242,7 @@ func main() { inUseFiles = append(inUseFiles, absPath(name)) f, err := os.Open(name) if err != nil { - errorf("failed to open input file %q: %v", name, err) + tui.Errorf("failed to open input file %q: %v", name, err) } defer f.Close() in = f @@ -251,9 +252,9 @@ func main() { // If the input comes from a TTY, assume it's armored, and buffer up // to the END line (or EOF/EOT) so that a password prompt or the // output don't get in the way of typing the input. See Issue 364. - buf, err := bufferTerminalInput(in) + buf, err := tui.BufferTerminalInput(in) if err != nil { - errorf("failed to buffer terminal input: %v", err) + tui.Errorf("failed to buffer terminal input: %v", err) } in = buf } @@ -261,13 +262,13 @@ func main() { if name := outFlag; name != "" && name != "-" { for _, f := range inUseFiles { if f == absPath(name) { - errorf("input and output file are the same: %q", name) + tui.Errorf("input and output file are the same: %q", name) } } f := newLazyOpener(name) defer func() { if err := f.Close(); err != nil { - errorf("failed to close output file %q: %v", name, err) + tui.Errorf("failed to close output file %q: %v", name, err) } }() out = f @@ -278,7 +279,7 @@ func main() { } else if !armorFlag { // If the output wouldn't be armored, refuse to send binary to // the terminal unless explicitly requested with "-o -". - errorWithHint("refusing to output binary to the terminal", + tui.ErrorWithHint("refusing to output binary to the terminal", "did you mean to use -a/--armor?", `force anyway with "-o -"`) } @@ -305,7 +306,7 @@ func main() { } func passphrasePromptForEncryption() (string, error) { - pass, err := readSecret("Enter passphrase (leave empty to autogenerate a secure one):") + pass, err := tui.ReadSecret("Enter passphrase (leave empty to autogenerate a secure one):") if err != nil { return "", fmt.Errorf("could not read passphrase: %v", err) } @@ -316,12 +317,12 @@ func passphrasePromptForEncryption() (string, error) { words = append(words, randomWord()) } p = strings.Join(words, "-") - err := printfToTerminal("using autogenerated passphrase %q", p) + err := tui.PrintfToTerminal("using autogenerated passphrase %q", p) if err != nil { return "", fmt.Errorf("could not print passphrase: %v", err) } } else { - confirm, err := readSecret("Confirm passphrase:") + confirm, err := tui.ReadSecret("Confirm passphrase:") if err != nil { return "", fmt.Errorf("could not read passphrase: %v", err) } @@ -337,19 +338,19 @@ func encryptNotPass(recs, files []string, identities identityFlags, in io.Reader for _, arg := range recs { r, err := parseRecipient(arg) if err, ok := err.(gitHubRecipientError); ok { - errorWithHint(err.Error(), "instead, use recipient files like", + tui.ErrorWithHint(err.Error(), "instead, use recipient files like", " curl -O https://github.com/"+err.username+".keys", " age -R "+err.username+".keys") } if err != nil { - errorf("%v", err) + tui.Errorf("%v", err) } recipients = append(recipients, r) } for _, name := range files { recs, err := parseRecipientsFile(name) if err != nil { - errorf("failed to parse recipient file %q: %v", name, err) + tui.Errorf("failed to parse recipient file %q: %v", name, err) } recipients = append(recipients, recs...) } @@ -358,17 +359,17 @@ func encryptNotPass(recs, files []string, identities identityFlags, in io.Reader case "i": ids, err := parseIdentitiesFile(f.Value) if err != nil { - errorf("reading %q: %v", f.Value, err) + tui.Errorf("reading %q: %v", f.Value, err) } r, err := identitiesToRecipients(ids) if err != nil { - errorf("internal error processing %q: %v", f.Value, err) + tui.Errorf("internal error processing %q: %v", f.Value, err) } recipients = append(recipients, r...) case "j": - id, err := plugin.NewIdentityWithoutData(f.Value, pluginTerminalUI) + id, err := plugin.NewIdentityWithoutData(f.Value, tui.PluginTerminalUI) if err != nil { - errorf("initializing %q: %v", f.Value, err) + tui.Errorf("initializing %q: %v", f.Value, err) } recipients = append(recipients, id.Recipient()) } @@ -379,12 +380,12 @@ func encryptNotPass(recs, files []string, identities identityFlags, in io.Reader func encryptPass(in io.Reader, out io.Writer, armor bool) { pass, err := passphrasePromptForEncryption() if err != nil { - errorf("%v", err) + tui.Errorf("%v", err) } r, err := age.NewScryptRecipient(pass) if err != nil { - errorf("%v", err) + tui.Errorf("%v", err) } testOnlyConfigureScryptIdentity(r) encrypt([]age.Recipient{r}, in, out, armor) @@ -397,20 +398,20 @@ func encrypt(recipients []age.Recipient, in io.Reader, out io.Writer, withArmor a := armor.NewWriter(out) defer func() { if err := a.Close(); err != nil { - errorf("%v", err) + tui.Errorf("%v", err) } }() out = a } w, err := age.Encrypt(out, recipients...) if err != nil { - errorf("%v", err) + tui.Errorf("%v", err) } if _, err := io.Copy(w, in); err != nil { - errorf("%v", err) + tui.Errorf("%v", err) } if err := w.Close(); err != nil { - errorf("%v", err) + tui.Errorf("%v", err) } } @@ -426,7 +427,7 @@ func (rejectScryptIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) { if len(stanzas) != 1 || stanzas[0].Type != "scrypt" { return nil, age.ErrIncorrectIdentity } - errorWithHint("file is passphrase-encrypted but identities were specified with -i/--identity or -j", + tui.ErrorWithHint("file is passphrase-encrypted but identities were specified with -i/--identity or -j", "remove all -i/--identity/-j flags to decrypt passphrase-encrypted files") panic("unreachable") } @@ -439,13 +440,13 @@ func decryptNotPass(flags identityFlags, in io.Reader, out io.Writer) { case "i": ids, err := parseIdentitiesFile(f.Value) if err != nil { - errorf("reading %q: %v", f.Value, err) + tui.Errorf("reading %q: %v", f.Value, err) } identities = append(identities, ids...) case "j": - id, err := plugin.NewIdentityWithoutData(f.Value, pluginTerminalUI) + id, err := plugin.NewIdentityWithoutData(f.Value, tui.PluginTerminalUI) if err != nil { - errorf("initializing %q: %v", f.Value, err) + tui.Errorf("initializing %q: %v", f.Value, err) } identities = append(identities, id) } @@ -468,7 +469,7 @@ func decrypt(identities []age.Identity, in io.Reader, out io.Writer) { rr := bufio.NewReader(in) if intro, _ := rr.Peek(len(crlfMangledIntro)); string(intro) == crlfMangledIntro || string(intro) == utf16MangledIntro { - errorWithHint("invalid header intro", + tui.ErrorWithHint("invalid header intro", "it looks like this file was corrupted by PowerShell redirection", "consider using -o or -a to encrypt files in PowerShell") } @@ -481,16 +482,16 @@ func decrypt(identities []age.Identity, in io.Reader, out io.Writer) { r, err := age.Decrypt(in, identities...) if err != nil { - errorf("%v", err) + tui.Errorf("%v", err) } out.Write(nil) // trigger the lazyOpener even if r is empty if _, err := io.Copy(out, r); err != nil { - errorf("%v", err) + tui.Errorf("%v", err) } } func passphrasePromptForDecryption() (string, error) { - pass, err := readSecret("Enter passphrase:") + pass, err := tui.ReadSecret("Enter passphrase:") if err != nil { return "", fmt.Errorf("could not read passphrase: %v", err) } diff --git a/cmd/age/age_test.go b/cmd/age/age_test.go index 92918299..757bbf7a 100644 --- a/cmd/age/age_test.go +++ b/cmd/age/age_test.go @@ -6,6 +6,7 @@ package main import ( "bufio" + "filippo.io/age/tui" "os" "testing" @@ -16,9 +17,9 @@ import ( func TestMain(m *testing.M) { os.Exit(testscript.RunMain(m, map[string]func() int{ "age": func() (exitCode int) { - testOnlyPanicInsteadOfExit = true + tui.TestOnlyPanicInsteadOfExit = true defer func() { - if testOnlyDidExit { + if tui.TestOnlyDidExit { exitCode = recover().(int) } }() diff --git a/cmd/age/parse.go b/cmd/age/parse.go index 4a59e7a4..1a1e0434 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -7,6 +7,7 @@ package main import ( "bufio" "encoding/base64" + "filippo.io/age/tui" "fmt" "io" "os" @@ -31,7 +32,7 @@ func (gitHubRecipientError) Error() string { func parseRecipient(arg string) (age.Recipient, error) { switch { case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1: - return plugin.NewRecipient(arg, pluginTerminalUI) + return plugin.NewRecipient(arg, tui.PluginTerminalUI) case strings.HasPrefix(arg, "age1"): return age.ParseX25519Recipient(arg) case strings.HasPrefix(arg, "ssh-"): @@ -79,7 +80,7 @@ func parseRecipientsFile(name string) ([]age.Recipient, error) { if err != nil { if t, ok := sshKeyType(line); ok { // Skip unsupported but valid SSH public keys with a warning. - warningf("recipients file %q: ignoring unsupported SSH key of type %q at line %d", name, t, n) + tui.Warningf("recipients file %q: ignoring unsupported SSH key of type %q at line %d", name, t, n) continue } // Hide the error since it might unintentionally leak the contents @@ -162,14 +163,14 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) { return []age.Identity{&EncryptedIdentity{ Contents: contents, Passphrase: func() (string, error) { - pass, err := readSecret(fmt.Sprintf("Enter passphrase for identity file %q:", name)) + pass, err := tui.ReadSecret(fmt.Sprintf("Enter passphrase for identity file %q:", name)) if err != nil { return "", fmt.Errorf("could not read passphrase: %v", err) } return string(pass), nil }, NoMatchWarning: func() { - warningf("encrypted identity file %q didn't match file's recipients", name) + tui.Warningf("encrypted identity file %q didn't match file's recipients", name) }, }}, nil @@ -198,7 +199,7 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) { func parseIdentity(s string) (age.Identity, error) { switch { case strings.HasPrefix(s, "AGE-PLUGIN-"): - return plugin.NewIdentity(s, pluginTerminalUI) + return plugin.NewIdentity(s, tui.PluginTerminalUI) case strings.HasPrefix(s, "AGE-SECRET-KEY-1"): return age.ParseX25519Identity(s) default: @@ -246,7 +247,7 @@ func parseSSHIdentity(name string, pemBytes []byte) ([]age.Identity, error) { } } passphrasePrompt := func() ([]byte, error) { - pass, err := readSecret(fmt.Sprintf("Enter passphrase for %q:", name)) + pass, err := tui.ReadSecret(fmt.Sprintf("Enter passphrase for %q:", name)) if err != nil { return nil, fmt.Errorf("could not read passphrase for %q: %v", name, err) } diff --git a/cmd/age/tui.go b/tui/tui.go similarity index 75% rename from cmd/age/tui.go rename to tui/tui.go index c0b1b13a..f0de9f53 100644 --- a/cmd/age/tui.go +++ b/tui/tui.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package main +package tui // This file implements the terminal UI of cmd/age. The rules are: // @@ -30,46 +30,47 @@ import ( // l is a logger with no prefixes. var l = log.New(os.Stderr, "", 0) -func printf(format string, v ...interface{}) { +func Printf(format string, v ...interface{}) { l.Printf("age: "+format, v...) } -func errorf(format string, v ...interface{}) { +func Errorf(format string, v ...interface{}) { l.Printf("age: error: "+format, v...) l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report") - exit(1) + Exit(1) } -func warningf(format string, v ...interface{}) { +func Warningf(format string, v ...interface{}) { l.Printf("age: warning: "+format, v...) } -func errorWithHint(error string, hints ...string) { +func ErrorWithHint(error string, hints ...string) { l.Printf("age: error: %s", error) for _, hint := range hints { l.Printf("age: hint: %s", hint) } l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report") - exit(1) + Exit(1) } -// If testOnlyPanicInsteadOfExit is true, exit will set testOnlyDidExit and +// If TestOnlyPanicInsteadOfExit is true, exit will set TestOnlyDidExit and // panic instead of calling os.Exit. This way, the wrapper in TestMain can // recover the panic and return the exit code only if it was originated in exit. -var testOnlyPanicInsteadOfExit bool -var testOnlyDidExit bool -func exit(code int) { - if testOnlyPanicInsteadOfExit { - testOnlyDidExit = true +var TestOnlyPanicInsteadOfExit bool +var TestOnlyDidExit bool + +func Exit(code int) { + if TestOnlyPanicInsteadOfExit { + TestOnlyDidExit = true panic(code) } os.Exit(code) } -// clearLine clears the current line on the terminal, or opens a new line if +// ClearLine clears the current line on the terminal, or opens a new line if // terminal escape codes don't work. -func clearLine(out io.Writer) { +func ClearLine(out io.Writer) { const ( CUI = "\033[" // Control Sequence Introducer CPL = CUI + "F" // Cursor Previous Line @@ -86,10 +87,10 @@ func clearLine(out io.Writer) { fmt.Fprintf(out, "\r\n"+CPL+EL) } -// withTerminal runs f with the terminal input and output files, if available. -// withTerminal does not open a non-terminal stdin, so the caller does not need +// WithTerminal runs f with the terminal input and output files, if available. +// WithTerminal does not open a non-terminal stdin, so the caller does not need // to check stdinInUse. -func withTerminal(f func(in, out *os.File) error) error { +func WithTerminal(f func(in, out *os.File) error) error { if runtime.GOOS == "windows" { in, err := os.OpenFile("CONIN$", os.O_RDWR, 0) if err != nil { @@ -112,18 +113,18 @@ func withTerminal(f func(in, out *os.File) error) error { } } -func printfToTerminal(format string, v ...interface{}) error { - return withTerminal(func(_, out *os.File) error { +func PrintfToTerminal(format string, v ...interface{}) error { + return WithTerminal(func(_, out *os.File) error { _, err := fmt.Fprintf(out, "age: "+format+"\n", v...) return err }) } -// readSecret reads a value from the terminal with no echo. The prompt is ephemeral. -func readSecret(prompt string) (s []byte, err error) { - err = withTerminal(func(in, out *os.File) error { +// ReadSecret reads a value from the terminal with no echo. The prompt is ephemeral. +func ReadSecret(prompt string) (s []byte, err error) { + err = WithTerminal(func(in, out *os.File) error { fmt.Fprintf(out, "%s ", prompt) - defer clearLine(out) + defer ClearLine(out) s, err = term.ReadPassword(int(in.Fd())) return err }) @@ -133,9 +134,9 @@ func readSecret(prompt string) (s []byte, err error) { // readCharacter reads a single character from the terminal with no echo. The // prompt is ephemeral. func readCharacter(prompt string) (c byte, err error) { - err = withTerminal(func(in, out *os.File) error { + err = WithTerminal(func(in, out *os.File) error { fmt.Fprintf(out, "%s ", prompt) - defer clearLine(out) + defer ClearLine(out) oldState, err := term.MakeRaw(int(in.Fd())) if err != nil { @@ -154,18 +155,18 @@ func readCharacter(prompt string) (c byte, err error) { return } -var pluginTerminalUI = &plugin.ClientUI{ +var PluginTerminalUI = &plugin.ClientUI{ DisplayMessage: func(name, message string) error { - printf("%s plugin: %s", name, message) + Printf("%s plugin: %s", name, message) return nil }, RequestValue: func(name, message string, _ bool) (s string, err error) { defer func() { if err != nil { - warningf("could not read value for age-plugin-%s: %v", name, err) + Warningf("could not read value for age-plugin-%s: %v", name, err) } }() - secret, err := readSecret(message) + secret, err := ReadSecret(message) if err != nil { return "", err } @@ -174,12 +175,12 @@ var pluginTerminalUI = &plugin.ClientUI{ Confirm: func(name, message, yes, no string) (choseYes bool, err error) { defer func() { if err != nil { - warningf("could not read value for age-plugin-%s: %v", name, err) + Warningf("could not read value for age-plugin-%s: %v", name, err) } }() if no == "" { message += fmt.Sprintf(" (press enter for %q)", yes) - _, err := readSecret(message) + _, err := ReadSecret(message) if err != nil { return false, err } @@ -199,16 +200,16 @@ var pluginTerminalUI = &plugin.ClientUI{ case '\x03': // CTRL-C return false, errors.New("user cancelled prompt") default: - warningf("reading value for age-plugin-%s: invalid selection %q", name, selection) + Warningf("reading value for age-plugin-%s: invalid selection %q", name, selection) } } }, WaitTimer: func(name string) { - printf("waiting on %s plugin...", name) + Printf("waiting on %s plugin...", name) }, } -func bufferTerminalInput(in io.Reader) (io.Reader, error) { +func BufferTerminalInput(in io.Reader) (io.Reader, error) { buf := &bytes.Buffer{} if _, err := buf.ReadFrom(ReaderFunc(func(p []byte) (n int, err error) { if bytes.Contains(buf.Bytes(), []byte(armor.Footer+"\n")) {