Skip to content

Commit

Permalink
Move terminal related functions to internal/term
Browse files Browse the repository at this point in the history
  • Loading branch information
nicdumz committed Jan 13, 2025
1 parent b10e57a commit b9e3d10
Show file tree
Hide file tree
Showing 4 changed files with 113 additions and 95 deletions.
14 changes: 7 additions & 7 deletions cmd/age/age.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -248,7 +248,7 @@ func main() {
in = f
} else {
stdinInUse = true
if decryptFlag && term.IsTerminal(int(os.Stdin.Fd())) {
if decryptFlag && term.IsTerminal() {
// 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.
Expand All @@ -272,7 +272,7 @@ func main() {
}
}()
out = f
} else if term.IsTerminal(int(os.Stdout.Fd())) {
} else if term.IsTerminal() {
if name != "-" {
if decryptFlag {
// TODO: buffer the output and check it's printable.
Expand All @@ -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() {
// 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{}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
5 changes: 3 additions & 2 deletions cmd/age/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
91 changes: 5 additions & 86 deletions cmd/age/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand All @@ -139,15 +58,15 @@ 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
}
return true, nil
}
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
}
Expand Down
98 changes: 98 additions & 0 deletions internal/term/term.go
Original file line number Diff line number Diff line change
@@ -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)
}

// 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
}

// 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() {
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 IsTerminal() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}

0 comments on commit b9e3d10

Please sign in to comment.