term.ReadPassword reads the terminal with a raw read that returns 0 bytes at the end of input and takes that as nothing yet, so Ctrl-D at the prompt did nothing. On the Unix systems the prompt now sets the terminal as ReadPassword does and reads the line from standard input, which reports the end of input; elsewhere, such as Windows, it still uses ReadPassword. Ctrl-D with nothing typed gives an empty mnemonic, refused with status 1, and the terminal settings are put back. A test types Ctrl-D at the prompt on a pseudo-terminal. Unverified: the prompt was run only on Linux; other systems were built. Deviation: a Ctrl-H byte is no longer a backspace at the prompt. Model: opus-5-5 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
This commit was merged in pull request #90.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/creack/pty"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/term"
|
||||
"sneak.berlin/go/keyfunc/internal/mnemonic"
|
||||
)
|
||||
|
||||
// TestEndOfInputAtThePromptEndsTheTool runs "age pub" with no mnemonic
|
||||
// given and a terminal on all three streams, waits for the prompt, and
|
||||
// types Ctrl-D. The tool has to end with status 1, say that the
|
||||
// mnemonic is empty, and leave the terminal set as it found it, so with
|
||||
// echo on again.
|
||||
//
|
||||
// pty.Open returns the two ends of a new terminal: tty is the end the
|
||||
// tool uses as its terminal, and ptmx the end the test reads what the
|
||||
// terminal shows from and types into. Reading ptmx stops at the
|
||||
// deadline, so a prompt that never comes fails the test instead of
|
||||
// hanging it.
|
||||
func TestEndOfInputAtThePromptEndsTheTool(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, "")
|
||||
t.Setenv(mnemonic.CommandVariable, "")
|
||||
|
||||
ptmx, tty, err := pty.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = ptmx.Close() }()
|
||||
defer func() { _ = tty.Close() }()
|
||||
|
||||
require.NoError(t, ptmx.SetReadDeadline(time.Now().Add(10*time.Second)))
|
||||
|
||||
before, err := term.GetState(int(tty.Fd()))
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:gosec // this test's own binary as the tool
|
||||
command := exec.CommandContext(t.Context(), os.Args[0], "age", "pub")
|
||||
|
||||
command.Env = append(os.Environ(), runAsTool+"=1")
|
||||
command.Stdin = tty
|
||||
command.Stdout = tty
|
||||
command.Stderr = tty
|
||||
|
||||
require.NoError(t, command.Start())
|
||||
|
||||
var (
|
||||
shown string
|
||||
char byte
|
||||
)
|
||||
|
||||
terminal := bufio.NewReader(ptmx)
|
||||
for !strings.HasSuffix(shown, "mnemonic: ") {
|
||||
char, err = terminal.ReadByte()
|
||||
require.NoError(t, err, "the tool did not prompt: %q", shown)
|
||||
|
||||
shown += string(char)
|
||||
}
|
||||
|
||||
_, err = ptmx.WriteString("\x04") // Ctrl-D
|
||||
require.NoError(t, err)
|
||||
|
||||
waitForTool(t, "Ctrl-D at the prompt", command)
|
||||
require.Equal(t, failedStatus, command.ProcessState.ExitCode())
|
||||
|
||||
after, err := term.GetState(int(tty.Fd()))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, before, after, "the terminal settings were not restored")
|
||||
|
||||
// With the tool ended and this end closed too, ptmx gives what is
|
||||
// left on the terminal and then an error.
|
||||
require.NoError(t, tty.Close())
|
||||
|
||||
said, _ := io.ReadAll(terminal)
|
||||
require.Contains(t, string(said), mnemonic.ErrEmpty.Error())
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func ask() (string, error) {
|
||||
|
||||
fmt.Fprint(os.Stderr, "mnemonic: ")
|
||||
|
||||
typed, err := term.ReadPassword(fd)
|
||||
typed, err := readWithoutEcho()
|
||||
|
||||
fmt.Fprintln(os.Stderr)
|
||||
|
||||
@@ -144,7 +144,7 @@ func ask() (string, error) {
|
||||
return "", fmt.Errorf("reading the mnemonic: %w", err)
|
||||
}
|
||||
|
||||
return checked(string(typed))
|
||||
return checked(typed)
|
||||
}
|
||||
|
||||
// checked joins the words with single spaces, whatever whitespace
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !zos
|
||||
|
||||
package mnemonic
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// readWithoutEcho reads one line from the terminal on standard input
|
||||
// with echo turned off, through term.ReadPassword, on the systems
|
||||
// prompt_unix.go is not built for, such as Windows.
|
||||
func readWithoutEcho() (string, error) {
|
||||
typed, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
|
||||
return string(typed), err
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || zos
|
||||
|
||||
package mnemonic
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// readWithoutEcho reads one line from the terminal on standard input
|
||||
// with echo turned off. The line ends at a newline or at the end of
|
||||
// input (Ctrl-D). The terminal is set as term.ReadPassword sets it;
|
||||
// ReadPassword itself is not used here because it never sees the end
|
||||
// of input and goes on waiting.
|
||||
func readWithoutEcho() (string, error) {
|
||||
fd := int(os.Stdin.Fd())
|
||||
|
||||
settings, err := unix.IoctlGetTermios(fd, ioctlReadTermios)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading the terminal settings: %w", err)
|
||||
}
|
||||
|
||||
hidden := *settings
|
||||
hidden.Lflag &^= unix.ECHO
|
||||
hidden.Lflag |= unix.ICANON | unix.ISIG
|
||||
hidden.Iflag |= unix.ICRNL
|
||||
|
||||
err = unix.IoctlSetTermios(fd, ioctlWriteTermios, &hidden)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("turning echo off: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = unix.IoctlSetTermios(fd, ioctlWriteTermios, settings) }()
|
||||
|
||||
// With ICANON set, a read from the terminal returns at most one
|
||||
// line, so the buffer takes nothing past the newline.
|
||||
typed, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if errors.Is(err, io.EOF) {
|
||||
return typed, nil
|
||||
}
|
||||
|
||||
return typed, err
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build darwin || dragonfly || freebsd || netbsd || openbsd
|
||||
|
||||
package mnemonic
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
// The requests that read and set the terminal settings, as
|
||||
// golang.org/x/term names them.
|
||||
const (
|
||||
ioctlReadTermios = unix.TIOCGETA
|
||||
ioctlWriteTermios = unix.TIOCSETA
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build aix || linux || solaris || zos
|
||||
|
||||
package mnemonic
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
// The requests that read and set the terminal settings, as
|
||||
// golang.org/x/term names them.
|
||||
const (
|
||||
ioctlReadTermios = unix.TCGETS
|
||||
ioctlWriteTermios = unix.TCSETS
|
||||
)
|
||||
Reference in New Issue
Block a user