check / check (push) Waiting to run
The passphrase and mnemonic prompts open /dev/tty and ask there, so `echo v | secret add s1 --force`, `secret encrypt` and `secret decrypt` with piped input ask at a terminal instead of failing. Only a process with no controlling terminal still gets the error naming the variable to set. `secret init` and `secret vault create` no longer write a newline to stderr after the mnemonic. On SIGINT or SIGTERM, Entry() restores the settings of /dev/tty instead of stdin, since that is where the prompt turns echo off. The two tests that pin the no-terminal message run the binary in a new session, so it has no terminal wherever the tests run. Unverified by a test: the echo restore after Ctrl-C, checked by hand. Model: opus-5-5
205 lines
5.7 KiB
Go
205 lines
5.7 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
|
|
"filippo.io/age"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/creack/pty"
|
|
"github.com/spf13/afero"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/secret/internal/secret"
|
|
"sneak.berlin/go/secret/internal/vault"
|
|
)
|
|
|
|
// TestPassphraseAskedAtTerminalWithPipedStdin is a regression test for
|
|
// https://git.eeqj.de/sneak/secret/issues/144: run at a terminal with stdin
|
|
// a pipe, as in `echo v2 | secret add s1 --force`, a command that needed
|
|
// the passphrase failed instead of asking for it. Each command here must ask
|
|
// on the terminal and succeed once the passphrase is typed there.
|
|
func TestPassphraseAskedAtTerminalWithPipedStdin(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stateDir := t.TempDir()
|
|
|
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
defer passphrase.Destroy()
|
|
|
|
vlt, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default",
|
|
testMnemonicBuffer(t), passphrase)
|
|
require.NoError(t, err)
|
|
|
|
value := memguard.NewBufferFromBytes([]byte("v1"))
|
|
defer value.Destroy()
|
|
|
|
require.NoError(t, vlt.AddSecret("s1", value, false))
|
|
|
|
identity, err := age.GenerateX25519Identity()
|
|
require.NoError(t, err)
|
|
|
|
key := secret.IdentityToLockedBuffer(identity)
|
|
defer key.Destroy()
|
|
|
|
require.NoError(t, vlt.AddSecret("mykey", key, false))
|
|
|
|
runAtTerminal(t, stateDir, "v2\n", "add", "s1", "--force")
|
|
|
|
vlt.UnlockPassphrase = passphrase
|
|
|
|
added, err := vlt.GetSecret("s1")
|
|
require.NoError(t, err)
|
|
|
|
defer added.Destroy()
|
|
|
|
assert.Equal(t, "v2", added.String())
|
|
|
|
encrypted := runAtTerminal(t, stateDir, "a document\n", "encrypt", "mykey")
|
|
decrypted := runAtTerminal(t, stateDir, encrypted, "decrypt", "mykey")
|
|
assert.Equal(t, "a document\n", decrypted)
|
|
}
|
|
|
|
// TestInitWritesNoBlankLineAfterMnemonic runs `secret init 2>log` at a
|
|
// terminal: the terminal is its controlling terminal and none of stdin,
|
|
// stdout and stderr. The mnemonic is typed on the terminal and the prompt
|
|
// ends its line there, so stderr must not get a newline for it as well, a
|
|
// blank line in the log. pty.Open is described above
|
|
// TestRemoveIgnoresTerminalOnStdout.
|
|
func TestInitWritesNoBlankLineAfterMnemonic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
|
defer cancel()
|
|
|
|
stateDir := t.TempDir()
|
|
|
|
//nolint:gosec // G204: test executes the freshly built secret binary
|
|
cmd := exec.CommandContext(ctx, secretBinaryPath(t), "init")
|
|
cmd.Env = []string{
|
|
secret.EnvStateDir + "=" + stateDir,
|
|
secret.EnvUnlockPassphrase + "=" + testPassphrase,
|
|
"PATH=" + os.Getenv("PATH"),
|
|
"HOME=" + os.Getenv("HOME"),
|
|
}
|
|
|
|
ptmx, tty, err := pty.Open()
|
|
require.NoError(t, err)
|
|
|
|
defer func() { _ = ptmx.Close() }()
|
|
|
|
deadline, _ := ctx.Deadline()
|
|
require.NoError(t, ptmx.SetReadDeadline(deadline))
|
|
|
|
var stderr strings.Builder
|
|
|
|
cmd.Stderr = &stderr
|
|
cmd.ExtraFiles = []*os.File{tty}
|
|
// Ctty is a descriptor in the new process: 3, the first of ExtraFiles.
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 3}
|
|
|
|
require.NoError(t, cmd.Start())
|
|
|
|
_ = tty.Close()
|
|
|
|
var (
|
|
shown []byte
|
|
char byte
|
|
)
|
|
|
|
terminal := bufio.NewReader(ptmx)
|
|
for !bytes.HasSuffix(shown, []byte("Enter your BIP39 mnemonic phrase: ")) {
|
|
char, err = terminal.ReadByte()
|
|
require.NoError(t, err, "secret init did not ask on the terminal: %s",
|
|
shown)
|
|
|
|
shown = append(shown, char)
|
|
}
|
|
|
|
_, err = ptmx.WriteString(testMnemonic + "\n")
|
|
require.NoError(t, err)
|
|
|
|
// The read ends once the command has exited and so closed the terminal.
|
|
rest, _ := io.ReadAll(terminal)
|
|
|
|
err = cmd.Wait()
|
|
require.NoError(t, err, "secret init: %s%s%s", shown, rest, stderr.String())
|
|
|
|
assert.True(t, strings.HasPrefix(stderr.String(),
|
|
"Initialized secrets manager at: "+stateDir+"\n\n"+
|
|
"Default vault created and configured\n"),
|
|
"stderr: %q", stderr.String())
|
|
}
|
|
|
|
// runAtTerminal runs the secret binary with args and input on stdin, in a
|
|
// new session whose controlling terminal is a new terminal, which is also
|
|
// its stderr: a command typed at a terminal with its input piped. pty.Open
|
|
// is described above TestRemoveIgnoresTerminalOnStdout. runAtTerminal waits
|
|
// for the passphrase prompt on the terminal, types the passphrase there,
|
|
// and returns what the command wrote to stdout once it has succeeded.
|
|
func runAtTerminal(t *testing.T, stateDir, input string, args ...string) string {
|
|
t.Helper()
|
|
|
|
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
|
defer cancel()
|
|
|
|
//nolint:gosec // G204: test executes the freshly built secret binary
|
|
cmd := exec.CommandContext(ctx, secretBinaryPath(t), args...)
|
|
cmd.Env = []string{
|
|
secret.EnvStateDir + "=" + stateDir,
|
|
"PATH=" + os.Getenv("PATH"),
|
|
"HOME=" + os.Getenv("HOME"),
|
|
}
|
|
|
|
ptmx, tty, err := pty.Open()
|
|
require.NoError(t, err)
|
|
|
|
defer func() { _ = ptmx.Close() }()
|
|
|
|
deadline, _ := ctx.Deadline()
|
|
require.NoError(t, ptmx.SetReadDeadline(deadline))
|
|
|
|
var stdout strings.Builder
|
|
|
|
cmd.Stdin = strings.NewReader(input)
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = tty
|
|
// Ctty is a descriptor in the new process: 2, its stderr.
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 2}
|
|
|
|
require.NoError(t, cmd.Start())
|
|
|
|
_ = tty.Close()
|
|
|
|
var (
|
|
shown []byte
|
|
char byte
|
|
)
|
|
|
|
terminal := bufio.NewReader(ptmx)
|
|
for !bytes.HasSuffix(shown, []byte("Enter unlock passphrase: ")) {
|
|
char, err = terminal.ReadByte()
|
|
require.NoError(t, err, "secret %v did not ask on the terminal: %s",
|
|
args, shown)
|
|
|
|
shown = append(shown, char)
|
|
}
|
|
|
|
_, err = ptmx.WriteString(testPassphrase + "\n")
|
|
require.NoError(t, err)
|
|
|
|
// The read ends once the command has exited and so closed the terminal.
|
|
rest, _ := io.ReadAll(terminal)
|
|
|
|
require.NoError(t, cmd.Wait(), "secret %v: %s%s", args, shown, rest)
|
|
|
|
return stdout.String()
|
|
}
|