feat: move tui code to tui package

Makes PluginTerminalUI available for sops (and other third parties) so they can more easily support plugins.
This commit is contained in:
Brian McGee 2024-10-02 10:26:17 +01:00
parent 482cf6fc9b
commit 266c094091
No known key found for this signature in database
GPG key ID: D49016E76AD1E8C0
4 changed files with 89 additions and 85 deletions

View file

@ -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)
}

View file

@ -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)
}
}()

View file

@ -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)
}

View file

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