Files
secret/internal/cli/entry_test.go
T
sneak e08c29438a
check / check (push) Waiting to run
Write encrypt and decrypt output only once they succeed (closes #143)
`secret encrypt` and `secret decrypt` emptied the file named by
`--output` before reading any input, so a failure left it empty, and a
file given as both `--input` and `--output` was emptied before it was
read. They now write into a temporary file of mode 0600 next to it, or
next to the path it is a symbolic link to, and rename that over it only
on success, using the new `secret.WriteFileAtomicFunc`, which
`secret.WriteFileAtomic` now calls. Ctrl-C or SIGTERM removes the
temporary file. A device or pipe given as `--output`, such as
`/dev/null`, is written to directly.

Judgement call: the device and pipe case goes beyond the plan.

Model: opus-5-5
2026-10-14 19:06:37 +00:00

165 lines
4.8 KiB
Go

package cli_test
import (
"bufio"
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/awnumar/memguard"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
)
// Entry must return its exit code rather than exit, so that its deferred
// memguard purge runs on the success and the error path alike.
//
//nolint:paralleltest // sets os.Args, and Entry wipes every buffer in the process
func TestEntryWipesBuffersAndReturnsExitCode(t *testing.T) {
savedArgs := os.Args
t.Cleanup(func() { os.Args = savedArgs })
tests := []struct {
args []string
exitCode int
}{
{args: []string{"secret", "--help"}, exitCode: 0},
{args: []string{"secret", "no-such-command"}, exitCode: 1},
}
for _, tt := range tests {
buf := memguard.NewBufferFromBytes([]byte("key material"))
os.Args = tt.args
assert.Equal(t, tt.exitCode, cli.Entry(), "exit code for %v", tt.args)
assert.False(t, buf.IsAlive(), "Entry left a buffer unwiped for %v", tt.args)
}
}
// Ctrl-C while `secret add` waits for the value on stdin must end the
// process through memguard's signal handler, which wipes every buffer and
// exits with status 1, not through Go's default handling, which kills the
// process with the buffers intact.
func TestInterruptExitsThroughMemguard(t *testing.T) {
t.Parallel()
const waitingForValue = "Reading secret value from stdin"
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
defer cancel()
wd, err := filepath.Abs("../..")
require.NoError(t, err)
secretPath := filepath.Join(wd, "secret")
env := []string{
secret.EnvStateDir + "=" + t.TempDir(),
secret.EnvMnemonic + "=" + testMnemonic,
secret.EnvUnlockPassphrase + "=test-passphrase",
"PATH=/usr/bin:/bin",
// The debug log on stderr shows when add starts waiting for the value.
"GODEBUG=berlin.sneak.pkg.secret",
}
//nolint:gosec // G204: test executes the freshly built secret binary
initCmd := exec.CommandContext(ctx, secretPath, "init")
initCmd.Env = env
output, err := initCmd.CombinedOutput()
require.NoError(t, err, "init should succeed: %s", output)
//nolint:gosec // G204: test executes the freshly built secret binary
addCmd := exec.CommandContext(ctx, secretPath, "add", "test/secret")
addCmd.Env = env
// Held open and never written, so add keeps waiting for the value.
stdin, err := addCmd.StdinPipe()
require.NoError(t, err)
defer func() { _ = stdin.Close() }()
stderr, err := addCmd.StderrPipe()
require.NoError(t, err)
require.NoError(t, addCmd.Start())
waiting := false
scanner := bufio.NewScanner(stderr)
for !waiting && scanner.Scan() {
waiting = strings.Contains(scanner.Text(), waitingForValue)
}
require.True(t, waiting, "add never logged %q", waitingForValue)
require.NoError(t, addCmd.Process.Signal(os.Interrupt))
err = addCmd.Wait()
var exitErr *exec.ExitError
require.ErrorAs(t, err, &exitErr)
assert.Equal(t, 1, exitErr.ExitCode(), "add ended with %v", err)
}
// Ctrl-C while encrypt or decrypt waits for input must remove the temporary
// file it is writing next to --output, which for decrypt holds part of the
// secret.
func TestInterruptRemovesTemporaryOutput(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
defer cancel()
secretPath := secretBinaryPath(t)
env := []string{
secret.EnvStateDir + "=" + t.TempDir(),
secret.EnvMnemonic + "=" + testMnemonic,
secret.EnvUnlockPassphrase + "=test-passphrase",
"PATH=/usr/bin:/bin",
}
//nolint:gosec // G204: test executes the freshly built secret binary
initCmd := exec.CommandContext(ctx, secretPath, "init")
initCmd.Env = env
output, err := initCmd.CombinedOutput()
require.NoError(t, err, "init should succeed: %s", output)
// encrypt stores the new key before it starts writing, so decrypt has it
for _, command := range []string{"encrypt", "decrypt"} {
outputDir := t.TempDir()
//nolint:gosec // G204: test executes the freshly built secret binary
cmd := exec.CommandContext(ctx, secretPath, command, "key",
"--output", filepath.Join(outputDir, "out"))
cmd.Env = env
// Held open and never written, so the command keeps waiting for
// input. Wait closes it.
_, err := cmd.StdinPipe()
require.NoError(t, err)
require.NoError(t, cmd.Start())
require.Eventually(t, func() bool {
entries, err := os.ReadDir(outputDir)
return err == nil && len(entries) > 0
}, commandWait, 10*time.Millisecond, "%s made no temporary file", command)
require.NoError(t, cmd.Process.Signal(os.Interrupt))
var exitErr *exec.ExitError
require.ErrorAs(t, cmd.Wait(), &exitErr)
entries, err := os.ReadDir(outputDir)
require.NoError(t, err)
assert.Empty(t, entries, "%s left its temporary file", command)
}
}