plugin: add NewTerminalUI

Closes #611
Closes #591

Co-authored-by: Nicolas Dumazet <nicdumz.commits@gmail.com>
This commit is contained in:
Filippo Valsorda 2025-12-24 17:14:05 +01:00
parent a62324430d
commit 92ac13f51c
7 changed files with 267 additions and 238 deletions

View file

@ -22,8 +22,8 @@ import (
"filippo.io/age"
"filippo.io/age/agessh"
"filippo.io/age/armor"
"filippo.io/age/internal/term"
"filippo.io/age/plugin"
"golang.org/x/term"
)
const usage = `Usage:
@ -251,7 +251,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.
@ -275,7 +275,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.
@ -287,7 +287,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{}
@ -309,7 +309,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)
}
@ -325,7 +325,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)
}
@ -370,7 +370,7 @@ func encryptNotPass(recs, files []string, identities identityFlags, in io.Reader
}
recipients = append(recipients, r...)
case "j":
id, err := plugin.NewIdentityWithoutData(f.Value, pluginTerminalUI)
id, err := plugin.NewIdentityWithoutData(f.Value, plugin.NewTerminalUI(printf, warningf))
if err != nil {
errorf("initializing %q: %v", f.Value, err)
}
@ -450,7 +450,7 @@ func decryptNotPass(flags identityFlags, in io.Reader, out io.Writer) {
}
identities = append(identities, ids...)
case "j":
id, err := plugin.NewIdentityWithoutData(f.Value, pluginTerminalUI)
id, err := plugin.NewIdentityWithoutData(f.Value, plugin.NewTerminalUI(printf, warningf))
if err != nil {
errorf("initializing %q: %v", f.Value, err)
}
@ -509,7 +509,7 @@ func decrypt(identities []age.Identity, in io.Reader, out io.Writer) {
var lazyScryptIdentity = &LazyScryptIdentity{passphrasePromptForDecryption}
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)
}

View file

@ -16,6 +16,7 @@ import (
"filippo.io/age"
"filippo.io/age/agessh"
"filippo.io/age/armor"
"filippo.io/age/internal/term"
"filippo.io/age/plugin"
"filippo.io/age/tag"
"golang.org/x/crypto/cryptobyte"
@ -37,7 +38,7 @@ func parseRecipient(arg string) (age.Recipient, error) {
case strings.HasPrefix(arg, "age1pq1"):
return age.ParseHybridRecipient(arg)
case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1:
return plugin.NewRecipient(arg, pluginTerminalUI)
return plugin.NewRecipient(arg, plugin.NewTerminalUI(printf, warningf))
case strings.HasPrefix(arg, "age1"):
return age.ParseX25519Recipient(arg)
case strings.HasPrefix(arg, "ssh-"):
@ -175,7 +176,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)
}
@ -211,7 +212,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, plugin.NewTerminalUI(printf, warningf))
case strings.HasPrefix(s, "AGE-SECRET-KEY-1"):
return age.ParseX25519Identity(s)
case strings.HasPrefix(s, "AGE-SECRET-KEY-PQ-1"):
@ -265,7 +266,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)
}

View file

@ -12,19 +12,19 @@ package main
//
// - Everything else goes to standard error with an "age:" prefix.
// No capitalized initials and no periods at the end.
//
// The one exception is the autogenerated passphrase, which goes to
// the terminal, since we really want it to reach the user only.
import (
"bytes"
"errors"
"fmt"
"io"
"log"
"os"
"runtime"
"filippo.io/age/armor"
"filippo.io/age/plugin"
"golang.org/x/term"
"filippo.io/age/internal/term"
)
// l is a logger with no prefixes.
@ -53,183 +53,13 @@ func errorWithHint(error string, hints ...string) {
os.Exit(1)
}
// avoidTerminalEscapeSequences is set if we need to avoid using escape
// sequences to prevent weird characters being printed to the console. This will
// happen on Windows when virtual terminal processing cannot be enabled.
var avoidTerminalEscapeSequences bool
// 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, if possible.
//
// (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")
if !avoidTerminalEscapeSequences {
fmt.Fprintf(out, 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
}
// readPublic reads a value from the terminal. The prompt is ephemeral.
func readPublic(prompt string) (s []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)
t := term.NewTerminal(in, "")
line, err := t.ReadLine()
s = []byte(line)
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 {
printf("%s plugin: %s", name, message)
return nil
},
RequestValue: func(name, message string, isSecret bool) (s string, err error) {
defer func() {
if err != nil {
warningf("could not read value for age-plugin-%s: %v", name, err)
}
}()
if isSecret {
secret, err := readSecret(message)
if err != nil {
return "", err
}
return string(secret), nil
} else {
public, err := readPublic(message)
if err != nil {
return "", err
}
return string(public), nil
}
},
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)
}
}()
if no == "" {
message += fmt.Sprintf(" (press enter for %q)", yes)
_, err := 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 := 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:
warningf("reading value for age-plugin-%s: invalid selection %q", name, selection)
}
}
},
WaitTimer: func(name string) {
printf("waiting on %s plugin...", name)
},
}
func bufferTerminalInput(in io.Reader) (io.Reader, error) {
buf := &bytes.Buffer{}
if _, err := buf.ReadFrom(ReaderFunc(func(p []byte) (n int, err error) {

View file

@ -1,50 +0,0 @@
// Copyright 2022 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 main
import (
"errors"
"os"
"syscall"
"golang.org/x/sys/windows"
)
// Some instances of the Windows Console (e.g., cmd.exe and Windows PowerShell)
// do not have the virtual terminal processing enabled, which is necessary to
// make terminal escape sequences work. For this reason the clearLine function
// may not properly work. Here we enable the virtual terminal processing, if
// possible.
//
// See https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences.
func init() {
const (
ENABLE_PROCESSED_OUTPUT uint32 = 0x1
ENABLE_VIRTUAL_TERMINAL_PROCESSING uint32 = 0x4
)
kernel32DLL := windows.NewLazySystemDLL("Kernel32.dll")
setConsoleMode := kernel32DLL.NewProc("SetConsoleMode")
if err := withTerminal(func(in, out *os.File) error {
var mode uint32
if err := syscall.GetConsoleMode(syscall.Handle(out.Fd()), &mode); err != nil {
return err
}
mode |= ENABLE_PROCESSED_OUTPUT
mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING
// If the SetConsoleMode function fails, the return value is zero.
// See https://learn.microsoft.com/en-us/windows/console/setconsolemode#return-value.
if ret, _, _ := setConsoleMode.Call(out.Fd(), uintptr(mode)); ret == 0 {
return errors.New("SetConsoleMode failed")
}
return nil
}); err != nil {
avoidTerminalEscapeSequences = true
}
}

122
internal/term/term.go Normal file
View file

@ -0,0 +1,122 @@
package term
import (
"fmt"
"os"
"runtime"
"golang.org/x/term"
)
// enableVirtualTerminalProcessing tries to enable virtual terminal processing
// on Windows. If it fails, avoid using escape sequences to prevent weird
// characters being printed to the console.
var enableVirtualTerminalProcessing func(out *os.File) error
// clearLine clears the current line on the terminal, or opens a new line if
// terminal escape codes don't work.
func clearLine(out *os.File) {
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, if possible.
//
// (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")
if enableVirtualTerminalProcessing == nil || enableVirtualTerminalProcessing(out) == nil {
fmt.Fprintf(out, 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 if stdin is in use.
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)
}
}
// 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
}
// ReadPublic reads a value from the terminal. The prompt is ephemeral.
func ReadPublic(prompt string) (s []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)
t := term.NewTerminal(in, "")
line, err := t.ReadLine()
s = []byte(line)
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
}
// IsTerminal returns whether the given file is a terminal.
func IsTerminal(f *os.File) bool {
return term.IsTerminal(int(f.Fd()))
}

View file

@ -0,0 +1,48 @@
// Copyright 2022 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 (
"errors"
"os"
"syscall"
"golang.org/x/sys/windows"
)
func init() {
enableVirtualTerminalProcessing = func(out *os.File) error {
// Some instances of the Windows Console (e.g., cmd.exe and Windows PowerShell)
// do not have the virtual terminal processing enabled, which is necessary to
// make terminal escape sequences work. For this reason the clearLine function
// may not properly work. Here we enable the virtual terminal processing, if
// possible.
//
// See https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences.
const (
ENABLE_PROCESSED_OUTPUT uint32 = 0x1
ENABLE_VIRTUAL_TERMINAL_PROCESSING uint32 = 0x4
)
kernel32DLL := windows.NewLazySystemDLL("Kernel32.dll")
setConsoleMode := kernel32DLL.NewProc("SetConsoleMode")
var mode uint32
if err := syscall.GetConsoleMode(syscall.Handle(out.Fd()), &mode); err != nil {
return err
}
mode |= ENABLE_PROCESSED_OUTPUT
mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING
// If the SetConsoleMode function fails, the return value is zero.
// See https://learn.microsoft.com/en-us/windows/console/setconsolemode#return-value.
if ret, _, _ := setConsoleMode.Call(out.Fd(), uintptr(mode)); ret == 0 {
return errors.New("SetConsoleMode failed")
}
return nil
}
}

78
plugin/tui.go Normal file
View file

@ -0,0 +1,78 @@
package plugin
import (
"errors"
"fmt"
"filippo.io/age/internal/term"
)
// NewTerminalUI returns a [ClientUI] that uses the terminal to request inputs,
// and the provided functions to display messages and errors.
//
// The terminal is reached directly through /dev/tty or CONIN$/CONOUT$,
// bypassing standard input and output, so this UI can be used even when
// standard input or output are redirected.
func NewTerminalUI(printf, warningf func(format string, v ...interface{})) *ClientUI {
return &ClientUI{
DisplayMessage: func(name, message string) error {
printf("%s plugin: %s", name, message)
return nil
},
RequestValue: func(name, message string, isSecret bool) (s string, err error) {
defer func() {
if err != nil {
warningf("could not read value for age-plugin-%s: %v", name, err)
}
}()
if isSecret {
secret, err := term.ReadSecret(message)
if err != nil {
return "", err
}
return string(secret), nil
} else {
public, err := term.ReadPublic(message)
if err != nil {
return "", err
}
return string(public), nil
}
},
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)
}
}()
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:
warningf("reading value for age-plugin-%s: invalid selection %q", name, selection)
}
}
},
WaitTimer: func(name string) {
printf("waiting on %s plugin...", name)
},
}
}