From b10e57ade9670cda46a8803dcf8741d2fb8dd2cc Mon Sep 17 00:00:00 2001 From: Nicolas Dumazet Date: Mon, 13 Jan 2025 22:53:50 +0100 Subject: [PATCH 1/3] Move logging functions to internal/logger. --- cmd/age/age.go | 71 ++++++++++++++++++++------------------- cmd/age/age_test.go | 5 +-- cmd/age/parse.go | 5 +-- cmd/age/tui.go | 52 ++++------------------------ internal/logger/logger.go | 52 ++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 85 deletions(-) create mode 100644 internal/logger/logger.go diff --git a/cmd/age/age.go b/cmd/age/age.go index e5d17e2b..4406d76b 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -19,6 +19,7 @@ import ( "filippo.io/age" "filippo.io/age/agessh" "filippo.io/age/armor" + "filippo.io/age/internal/logger" "filippo.io/age/plugin" "golang.org/x/term" ) @@ -104,7 +105,7 @@ func main() { if len(os.Args) == 1 { flag.Usage() - exit(1) + logger.Global.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...) + logger.Global.ErrorWithHint("too many INPUT arguments: "+quotedArgs, hints...) } switch { case decryptFlag: if encryptFlag { - errorf("-e/--encrypt can't be used with -d/--decrypt") + logger.Global.Errorf("-e/--encrypt can't be used with -d/--decrypt") } if armorFlag { - errorWithHint("-a/--armor can't be used with -d/--decrypt", + logger.Global.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", + logger.Global.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", + logger.Global.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", + logger.Global.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", + logger.Global.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", + logger.Global.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") + logger.Global.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") + logger.Global.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") + logger.Global.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) + logger.Global.Errorf("failed to open input file %q: %v", name, err) } defer f.Close() in = f @@ -253,7 +254,7 @@ func main() { // output don't get in the way of typing the input. See Issue 364. buf, err := bufferTerminalInput(in) if err != nil { - errorf("failed to buffer terminal input: %v", err) + logger.Global.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) + logger.Global.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) + logger.Global.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", + logger.Global.ErrorWithHint("refusing to output binary to the terminal", "did you mean to use -a/--armor?", `force anyway with "-o -"`) } @@ -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", + logger.Global.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) + logger.Global.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) + logger.Global.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) + logger.Global.Errorf("reading %q: %v", f.Value, err) } r, err := identitiesToRecipients(ids) if err != nil { - errorf("internal error processing %q: %v", f.Value, err) + logger.Global.Errorf("internal error processing %q: %v", f.Value, err) } recipients = append(recipients, r...) case "j": id, err := plugin.NewIdentityWithoutData(f.Value, pluginTerminalUI) if err != nil { - errorf("initializing %q: %v", f.Value, err) + logger.Global.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) + logger.Global.Errorf("%v", err) } r, err := age.NewScryptRecipient(pass) if err != nil { - errorf("%v", err) + logger.Global.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) + logger.Global.Errorf("%v", err) } }() out = a } w, err := age.Encrypt(out, recipients...) if err != nil { - errorf("%v", err) + logger.Global.Errorf("%v", err) } if _, err := io.Copy(w, in); err != nil { - errorf("%v", err) + logger.Global.Errorf("%v", err) } if err := w.Close(); err != nil { - errorf("%v", err) + logger.Global.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", + logger.Global.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) + logger.Global.Errorf("reading %q: %v", f.Value, err) } identities = append(identities, ids...) case "j": id, err := plugin.NewIdentityWithoutData(f.Value, pluginTerminalUI) if err != nil { - errorf("initializing %q: %v", f.Value, err) + logger.Global.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", + logger.Global.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,11 +482,11 @@ func decrypt(identities []age.Identity, in io.Reader, out io.Writer) { r, err := age.Decrypt(in, identities...) if err != nil { - errorf("%v", err) + logger.Global.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) + logger.Global.Errorf("%v", err) } } diff --git a/cmd/age/age_test.go b/cmd/age/age_test.go index 92918299..9c3aa0e4 100644 --- a/cmd/age/age_test.go +++ b/cmd/age/age_test.go @@ -10,15 +10,16 @@ import ( "testing" "filippo.io/age" + "filippo.io/age/internal/logger" "github.com/rogpeppe/go-internal/testscript" ) func TestMain(m *testing.M) { os.Exit(testscript.RunMain(m, map[string]func() int{ "age": func() (exitCode int) { - testOnlyPanicInsteadOfExit = true + logger.Global.TestOnlyPanicInsteadOfExit = true defer func() { - if testOnlyDidExit { + if logger.Global.TestOnlyDidExit { exitCode = recover().(int) } }() diff --git a/cmd/age/parse.go b/cmd/age/parse.go index 4a59e7a4..56c29b03 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -15,6 +15,7 @@ import ( "filippo.io/age" "filippo.io/age/agessh" "filippo.io/age/armor" + "filippo.io/age/internal/logger" "filippo.io/age/plugin" "golang.org/x/crypto/cryptobyte" "golang.org/x/crypto/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) + logger.Global.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 @@ -169,7 +170,7 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) { return string(pass), nil }, NoMatchWarning: func() { - warningf("encrypted identity file %q didn't match file's recipients", name) + logger.Global.Warningf("encrypted identity file %q didn't match file's recipients", name) }, }}, nil diff --git a/cmd/age/tui.go b/cmd/age/tui.go index c0b1b13a..f6b6af67 100644 --- a/cmd/age/tui.go +++ b/cmd/age/tui.go @@ -18,55 +18,15 @@ import ( "errors" "fmt" "io" - "log" "os" "runtime" "filippo.io/age/armor" + "filippo.io/age/internal/logger" "filippo.io/age/plugin" "golang.org/x/term" ) -// l is a logger with no prefixes. -var l = log.New(os.Stderr, "", 0) - -func printf(format string, v ...interface{}) { - l.Printf("age: "+format, v...) -} - -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) -} - -func warningf(format string, v ...interface{}) { - l.Printf("age: warning: "+format, v...) -} - -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) -} - -// 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 - panic(code) - } - os.Exit(code) -} - // 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) { @@ -156,13 +116,13 @@ func readCharacter(prompt string) (c byte, err error) { var pluginTerminalUI = &plugin.ClientUI{ DisplayMessage: func(name, message string) error { - printf("%s plugin: %s", name, message) + logger.Global.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) + logger.Global.Warningf("could not read value for age-plugin-%s: %v", name, err) } }() secret, err := readSecret(message) @@ -174,7 +134,7 @@ 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) + logger.Global.Warningf("could not read value for age-plugin-%s: %v", name, err) } }() if no == "" { @@ -199,12 +159,12 @@ 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) + logger.Global.Warningf("reading value for age-plugin-%s: invalid selection %q", name, selection) } } }, WaitTimer: func(name string) { - printf("waiting on %s plugin...", name) + logger.Global.Printf("waiting on %s plugin...", name) }, } diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 00000000..4bd4bdae --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,52 @@ +// Copyright 2021 The age Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package logger + +import ( + "log" + "os" +) + +type Logger struct { + ll *log.Logger + // 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. + TestOnlyPanicInsteadOfExit bool + TestOnlyDidExit bool +} + +var Global = &Logger{ll: log.New(os.Stderr, "", 0)} + +func (l *Logger) Exit(code int) { + if l.TestOnlyPanicInsteadOfExit { + l.TestOnlyDidExit = true + panic(code) + } + os.Exit(code) +} + +func (l *Logger) Printf(format string, v ...interface{}) { + l.ll.Printf("age: "+format, v...) +} + +func (l *Logger) Errorf(format string, v ...interface{}) { + l.Printf("error: "+format, v...) + l.Printf("report unexpected or unhelpful errors at https://filippo.io/age/report") + l.Exit(1) +} + +func (l *Logger) Warningf(format string, v ...interface{}) { + l.Printf("warning: "+format, v...) +} + +func (l *Logger) ErrorWithHint(error string, hints ...string) { + l.Printf("error: %s", error) + for _, hint := range hints { + l.Printf("hint: %s", hint) + } + l.Printf("report unexpected or unhelpful errors at https://filippo.io/age/report") + l.Exit(1) +} From fb455115387f64f6d9f1502bc6396941e6233ca4 Mon Sep 17 00:00:00 2001 From: Nicolas Dumazet Date: Mon, 13 Jan 2025 23:03:45 +0100 Subject: [PATCH 2/3] Move terminal related functions to internal/term --- cmd/age-keygen/keygen.go | 4 +- cmd/age/age.go | 14 +++--- cmd/age/parse.go | 5 +- cmd/age/tui.go | 91 ++----------------------------------- internal/term/term.go | 98 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 97 deletions(-) create mode 100644 internal/term/term.go diff --git a/cmd/age-keygen/keygen.go b/cmd/age-keygen/keygen.go index 549d3cf8..6556dcb6 100644 --- a/cmd/age-keygen/keygen.go +++ b/cmd/age-keygen/keygen.go @@ -14,7 +14,7 @@ import ( "time" "filippo.io/age" - "golang.org/x/term" + "filippo.io/age/internal/term" ) const usage = `Usage: @@ -126,7 +126,7 @@ func generate(out *os.File) { errorf("internal error: %v", err) } - if !term.IsTerminal(int(out.Fd())) { + if !term.IsTerminal(out) { fmt.Fprintf(os.Stderr, "Public key: %s\n", k.Recipient()) } diff --git a/cmd/age/age.go b/cmd/age/age.go index 4406d76b..64b9df8d 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -20,8 +20,8 @@ import ( "filippo.io/age/agessh" "filippo.io/age/armor" "filippo.io/age/internal/logger" + "filippo.io/age/internal/term" "filippo.io/age/plugin" - "golang.org/x/term" ) const usage = `Usage: @@ -248,7 +248,7 @@ func main() { in = f } else { stdinInUse = true - if decryptFlag && term.IsTerminal(int(os.Stdin.Fd())) { + if decryptFlag && term.IsTerminal(os.Stdin) { // 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. @@ -272,7 +272,7 @@ func main() { } }() out = f - } else if term.IsTerminal(int(os.Stdout.Fd())) { + } else if term.IsTerminal(os.Stdout) { if name != "-" { if decryptFlag { // TODO: buffer the output and check it's printable. @@ -284,7 +284,7 @@ func main() { `force anyway with "-o -"`) } } - if in == os.Stdin && term.IsTerminal(int(os.Stdin.Fd())) { + if in == os.Stdin && term.IsTerminal(os.Stdin) { // If the input comes from a TTY and output will go to a TTY, // buffer it up so it doesn't get in the way of typing the input. buf := &bytes.Buffer{} @@ -306,7 +306,7 @@ func main() { } func passphrasePromptForEncryption() (string, error) { - pass, err := readSecret("Enter passphrase (leave empty to autogenerate a secure one):") + pass, err := term.ReadSecret("Enter passphrase (leave empty to autogenerate a secure one):") if err != nil { return "", fmt.Errorf("could not read passphrase: %v", err) } @@ -322,7 +322,7 @@ func passphrasePromptForEncryption() (string, error) { return "", fmt.Errorf("could not print passphrase: %v", err) } } else { - confirm, err := readSecret("Confirm passphrase:") + confirm, err := term.ReadSecret("Confirm passphrase:") if err != nil { return "", fmt.Errorf("could not read passphrase: %v", err) } @@ -491,7 +491,7 @@ func decrypt(identities []age.Identity, in io.Reader, out io.Writer) { } func passphrasePromptForDecryption() (string, error) { - pass, err := readSecret("Enter passphrase:") + pass, err := term.ReadSecret("Enter passphrase:") if err != nil { return "", fmt.Errorf("could not read passphrase: %v", err) } diff --git a/cmd/age/parse.go b/cmd/age/parse.go index 56c29b03..3f9896f9 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -16,6 +16,7 @@ import ( "filippo.io/age/agessh" "filippo.io/age/armor" "filippo.io/age/internal/logger" + "filippo.io/age/internal/term" "filippo.io/age/plugin" "golang.org/x/crypto/cryptobyte" "golang.org/x/crypto/ssh" @@ -163,7 +164,7 @@ 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 := term.ReadSecret(fmt.Sprintf("Enter passphrase for identity file %q:", name)) if err != nil { return "", fmt.Errorf("could not read passphrase: %v", err) } @@ -247,7 +248,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 := term.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/cmd/age/tui.go index f6b6af67..1110a40d 100644 --- a/cmd/age/tui.go +++ b/cmd/age/tui.go @@ -19,101 +19,20 @@ import ( "fmt" "io" "os" - "runtime" "filippo.io/age/armor" "filippo.io/age/internal/logger" + "filippo.io/age/internal/term" "filippo.io/age/plugin" - "golang.org/x/term" ) -// 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) { - const ( - CUI = "\033[" // Control Sequence Introducer - CPL = CUI + "F" // Cursor Previous Line - EL = CUI + "K" // Erase in Line - ) - - // First, open a new line, which is guaranteed to work everywhere. Then, try - // to erase the line above with escape codes. - // - // (We use CRLF instead of LF to work around an apparent bug in WSL2's - // handling of CONOUT$. Only when running a Windows binary from WSL2, the - // cursor would not go back to the start of the line with a simple LF. - // Honestly, it's impressive CONIN$ and CONOUT$ work at all inside WSL2.) - 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 -// to check stdinInUse. -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 { - return err - } - defer in.Close() - out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0) - if err != nil { - return err - } - defer out.Close() - return f(in, out) - } else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil { - defer tty.Close() - return f(tty, tty) - } else if term.IsTerminal(int(os.Stdin.Fd())) { - return f(os.Stdin, os.Stdin) - } else { - return fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err) - } -} - func printfToTerminal(format string, v ...interface{}) error { - return withTerminal(func(_, out *os.File) error { + return term.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 { - fmt.Fprintf(out, "%s ", prompt) - defer clearLine(out) - s, err = term.ReadPassword(int(in.Fd())) - return err - }) - return -} - -// 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 { - fmt.Fprintf(out, "%s ", prompt) - defer clearLine(out) - - oldState, err := term.MakeRaw(int(in.Fd())) - if err != nil { - return err - } - defer term.Restore(int(in.Fd()), oldState) - - b := make([]byte, 1) - if _, err := in.Read(b); err != nil { - return err - } - - c = b[0] - return nil - }) - return -} - var pluginTerminalUI = &plugin.ClientUI{ DisplayMessage: func(name, message string) error { logger.Global.Printf("%s plugin: %s", name, message) @@ -125,7 +44,7 @@ var pluginTerminalUI = &plugin.ClientUI{ logger.Global.Warningf("could not read value for age-plugin-%s: %v", name, err) } }() - secret, err := readSecret(message) + secret, err := term.ReadSecret(message) if err != nil { return "", err } @@ -139,7 +58,7 @@ var pluginTerminalUI = &plugin.ClientUI{ }() if no == "" { message += fmt.Sprintf(" (press enter for %q)", yes) - _, err := readSecret(message) + _, err := term.ReadSecret(message) if err != nil { return false, err } @@ -147,7 +66,7 @@ var pluginTerminalUI = &plugin.ClientUI{ } message += fmt.Sprintf(" (press [1] for %q or [2] for %q)", yes, no) for { - selection, err := readCharacter(message) + selection, err := term.ReadCharacter(message) if err != nil { return false, err } diff --git a/internal/term/term.go b/internal/term/term.go new file mode 100644 index 00000000..597c0b16 --- /dev/null +++ b/internal/term/term.go @@ -0,0 +1,98 @@ +// Copyright 2021 The age Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package term + +import ( + "fmt" + "io" + "os" + "runtime" + + "golang.org/x/term" +) + +// 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) { + const ( + CUI = "\033[" // Control Sequence Introducer + CPL = CUI + "F" // Cursor Previous Line + EL = CUI + "K" // Erase in Line + ) + + // First, open a new line, which is guaranteed to work everywhere. Then, try + // to erase the line above with escape codes. + // + // (We use CRLF instead of LF to work around an apparent bug in WSL2's + // handling of CONOUT$. Only when running a Windows binary from WSL2, the + // cursor would not go back to the start of the line with a simple LF. + // Honestly, it's impressive CONIN$ and CONOUT$ work at all inside WSL2.) + 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 +// to check stdinInUse. +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 { + return err + } + defer in.Close() + out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0) + if err != nil { + return err + } + defer out.Close() + return f(in, out) + } else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil { + defer tty.Close() + return f(tty, tty) + } else if IsTerminal(os.Stdin) { + return f(os.Stdin, os.Stdin) + } else { + return fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err) + } +} + +// term.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) + s, err = term.ReadPassword(int(in.Fd())) + return err + }) + return +} + +// 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 { + fmt.Fprintf(out, "%s ", prompt) + defer clearLine(out) + + oldState, err := term.MakeRaw(int(in.Fd())) + if err != nil { + return err + } + defer term.Restore(int(in.Fd()), oldState) + + b := make([]byte, 1) + if _, err := in.Read(b); err != nil { + return err + } + + c = b[0] + return nil + }) + return +} + +func IsTerminal(f *os.File) bool { + return term.IsTerminal(int(f.Fd())) +} From 693d52e992c306e17a91aedce3e7e1b3b92be697 Mon Sep 17 00:00:00 2001 From: Nicolas Dumazet Date: Mon, 13 Jan 2025 23:12:35 +0100 Subject: [PATCH 3/3] Expose a public `plugin.NewClientUI()` This would allow `age` library users to create the needed inputs to interact with the `plugin` module. --- cmd/age/tui.go | 56 +-------------------------------------------- plugin/client.go | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 55 deletions(-) diff --git a/cmd/age/tui.go b/cmd/age/tui.go index 1110a40d..9b44b6a7 100644 --- a/cmd/age/tui.go +++ b/cmd/age/tui.go @@ -15,13 +15,11 @@ package main import ( "bytes" - "errors" "fmt" "io" "os" "filippo.io/age/armor" - "filippo.io/age/internal/logger" "filippo.io/age/internal/term" "filippo.io/age/plugin" ) @@ -33,59 +31,7 @@ func printfToTerminal(format string, v ...interface{}) error { }) } -var pluginTerminalUI = &plugin.ClientUI{ - DisplayMessage: func(name, message string) error { - logger.Global.Printf("%s plugin: %s", name, message) - return nil - }, - RequestValue: func(name, message string, _ bool) (s string, err error) { - defer func() { - if err != nil { - logger.Global.Warningf("could not read value for age-plugin-%s: %v", name, err) - } - }() - secret, err := term.ReadSecret(message) - if err != nil { - return "", err - } - return string(secret), nil - }, - Confirm: func(name, message, yes, no string) (choseYes bool, err error) { - defer func() { - if err != nil { - logger.Global.Warningf("could not read value for age-plugin-%s: %v", name, err) - } - }() - if no == "" { - message += fmt.Sprintf(" (press enter for %q)", yes) - _, err := term.ReadSecret(message) - if err != nil { - return false, err - } - return true, nil - } - message += fmt.Sprintf(" (press [1] for %q or [2] for %q)", yes, no) - for { - selection, err := term.ReadCharacter(message) - if err != nil { - return false, err - } - switch selection { - case '1': - return true, nil - case '2': - return false, nil - case '\x03': // CTRL-C - return false, errors.New("user cancelled prompt") - default: - logger.Global.Warningf("reading value for age-plugin-%s: invalid selection %q", name, selection) - } - } - }, - WaitTimer: func(name string) { - logger.Global.Printf("waiting on %s plugin...", name) - }, -} +var pluginTerminalUI = plugin.NewClientUI() func bufferTerminalInput(in io.Reader) (io.Reader, error) { buf := &bytes.Buffer{} diff --git a/plugin/client.go b/plugin/client.go index 051ec40b..8eccc554 100644 --- a/plugin/client.go +++ b/plugin/client.go @@ -9,6 +9,7 @@ package plugin import ( "bufio" + "errors" "fmt" "io" "math/rand" @@ -22,6 +23,8 @@ import ( "filippo.io/age" "filippo.io/age/internal/format" + "filippo.io/age/internal/logger" + "filippo.io/age/internal/term" ) type Recipient struct { @@ -321,6 +324,62 @@ type ClientUI struct { WaitTimer func(name string) } +func NewClientUI() *ClientUI { + return &ClientUI{ + DisplayMessage: func(name, message string) error { + logger.Global.Printf("%s plugin: %s", name, message) + return nil + }, + RequestValue: func(name, message string, _ bool) (s string, err error) { + defer func() { + if err != nil { + logger.Global.Warningf("could not read value for age-plugin-%s: %v", name, err) + } + }() + secret, err := term.ReadSecret(message) + if err != nil { + return "", err + } + return string(secret), nil + }, + Confirm: func(name, message, yes, no string) (choseYes bool, err error) { + defer func() { + if err != nil { + logger.Global.Warningf("could not read value for age-plugin-%s: %v", name, err) + } + }() + if no == "" { + message += fmt.Sprintf(" (press enter for %q)", yes) + _, err := term.ReadSecret(message) + if err != nil { + return false, err + } + return true, nil + } + message += fmt.Sprintf(" (press [1] for %q or [2] for %q)", yes, no) + for { + selection, err := term.ReadCharacter(message) + if err != nil { + return false, err + } + switch selection { + case '1': + return true, nil + case '2': + return false, nil + case '\x03': // CTRL-C + return false, errors.New("user cancelled prompt") + default: + logger.Global.Warningf("reading value for age-plugin-%s: invalid selection %q", name, selection) + } + } + }, + WaitTimer: func(name string) { + logger.Global.Printf("waiting on %s plugin...", name) + }, + } +} + func (c *ClientUI) handle(name string, conn *clientConnection, s *format.Stanza) (ok bool, err error) { switch s.Type { case "msg":