check / check (push) Failing after 3s
SIGINT, SIGTERM and SIGHUP were caught for the whole run, but only the ssh and sftp children acted on them: the mnemonic prompt waited for Enter, and an interrupted `age encrypt -o` put the encryption of the cut-off input in place. Now they end the tool at once, except where a command cleans up first: `ssh to` and `ssh install` while ssh or sftp runs, and `age encrypt -o` and `age decrypt -o` while they write, where a signal up to a tenth of a second after the input ends still removes the unfinished file and exits 1. Those commands catch only the signals the tool was not started ignoring, so a run under nohup survives a hangup. Model: opus-5-5
241 lines
7.2 KiB
Go
241 lines
7.2 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/keyfunc/internal/agekey"
|
|
"sneak.berlin/go/keyfunc/internal/mnemonic"
|
|
)
|
|
|
|
func TestTheAgeCommandsPrintTheKey(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
recipient := strings.TrimSpace(run(t, "age", "pub"))
|
|
require.True(t, strings.HasPrefix(recipient, "age1"))
|
|
|
|
identity := strings.TrimSpace(run(t, "age", "priv"))
|
|
require.True(t, strings.HasPrefix(identity, "AGE-SECRET-KEY-1"))
|
|
}
|
|
|
|
func TestAFileEncryptedByTheToolIsReadBackByIt(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
plain := written(t, "notes.txt", "the secret\n")
|
|
sealed := filepath.Join(t.TempDir(), "notes.age")
|
|
|
|
run(t, "age", "encrypt", "-o", sealed, plain)
|
|
require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed))
|
|
}
|
|
|
|
func TestTheArmoredFormIsTextThatDecrypts(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
plain := written(t, "notes.txt", "the secret\n")
|
|
|
|
armored := run(t, "age", "encrypt", "--armor", plain)
|
|
require.True(t, strings.HasPrefix(
|
|
armored, "-----BEGIN AGE ENCRYPTED FILE-----",
|
|
))
|
|
|
|
sealed := written(t, "notes.age", armored)
|
|
require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed))
|
|
}
|
|
|
|
func TestAnotherRecipientIsAddedAndTheDerivedOneStays(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
theirs := strings.TrimSpace(run(t, "age", "pub", "-n", "7"))
|
|
plain := written(t, "notes.txt", "the secret\n")
|
|
sealed := filepath.Join(t.TempDir(), "notes.age")
|
|
|
|
run(t, "age", "encrypt", "--to", theirs, "-o", sealed, plain)
|
|
|
|
require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed))
|
|
require.Equal(t,
|
|
"the secret\n", run(t, "age", "decrypt", "-n", "7", sealed),
|
|
)
|
|
}
|
|
|
|
func TestAFileForAnotherKeyIsRefused(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
plain := written(t, "notes.txt", "the secret\n")
|
|
sealed := filepath.Join(t.TempDir(), "notes.age")
|
|
|
|
run(t, "age", "encrypt", "-n", "7", "-o", sealed, plain)
|
|
|
|
_, err := execute(t, "age", "decrypt", sealed)
|
|
require.ErrorIs(t, err, agekey.ErrNotRecipient)
|
|
}
|
|
|
|
func TestARefusedDecryptionLeavesTheOutputFileAlone(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
plain := written(t, "notes.txt", "the secret\n")
|
|
sealed := filepath.Join(t.TempDir(), "notes.age")
|
|
existing := written(t, "notes.out", "what was already there\n")
|
|
|
|
run(t, "age", "encrypt", "-n", "7", "-o", sealed, plain)
|
|
|
|
_, err := execute(t, "age", "decrypt", "-o", existing, sealed)
|
|
require.ErrorIs(t, err, agekey.ErrNotRecipient)
|
|
|
|
//nolint:gosec // the test made this path itself
|
|
kept, err := os.ReadFile(existing)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "what was already there\n", string(kept))
|
|
}
|
|
|
|
func TestASignalStopsAnEncryptionAndLeavesNoFile(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
for _, ending := range []os.Signal{
|
|
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
|
|
} {
|
|
interrupted(t, ending, "encrypt", "the start of the secret\n", false)
|
|
}
|
|
}
|
|
|
|
func TestASignalStopsADecryptionAndLeavesNoFile(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
// All of an encryption but its last byte, so the tool reads the
|
|
// header and then waits for the rest.
|
|
sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n"))
|
|
cut := sealed[:len(sealed)-1]
|
|
|
|
for _, ending := range []os.Signal{
|
|
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
|
|
} {
|
|
interrupted(t, ending, "decrypt", cut, false)
|
|
}
|
|
}
|
|
|
|
func TestASignalAsTheInputEndsLeavesNoFile(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n"))
|
|
|
|
// Ctrl-C on "producer | keyfunc age encrypt -o file" ends the
|
|
// producer too, so the input ends just as the signal comes, with
|
|
// enough of it in hand for a whole encryption or decryption. Which
|
|
// of the two reaches the tool first varies, so it is tried often.
|
|
for range 25 {
|
|
interrupted(t, syscall.SIGINT, "encrypt", "the start of the secret\n", true)
|
|
interrupted(t, syscall.SIGINT, "decrypt", sealed, true)
|
|
}
|
|
}
|
|
|
|
func TestAnEncryptionStartedUnderNohupSurvivesAHangup(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
directory := t.TempDir()
|
|
named := filepath.Join(directory, "notes")
|
|
|
|
// nohup starts the tool with SIGHUP ignored. A tool that caught it
|
|
// anyway would turn it back on and be ended by it.
|
|
command, producer := writing(
|
|
t, directory, "the secret\n",
|
|
"nohup", os.Args[0], "age", "encrypt", "-o", named,
|
|
)
|
|
|
|
require.NoError(t, command.Process.Signal(syscall.SIGHUP))
|
|
require.NoError(t, producer.Close())
|
|
waitForTool(t, "SIGHUP under nohup", command)
|
|
|
|
require.Equal(t, 0, command.ProcessState.ExitCode())
|
|
|
|
left, err := os.ReadDir(directory)
|
|
require.NoError(t, err)
|
|
require.Len(t, left, 1)
|
|
require.Equal(t, "the secret\n", run(t, "age", "decrypt", named))
|
|
}
|
|
|
|
// interrupted runs "age encrypt -o" or "age decrypt -o", as the
|
|
// operation says, writing into a directory of its own, and once it has
|
|
// begun writing sends it the signal, then ends the input if endInput
|
|
// says so and otherwise leaves it open. The tool has to end with status
|
|
// 1 and leave the directory empty. A tool that went on reading would
|
|
// not end until the input did; one that did not remove the file it was
|
|
// writing would leave it there, with what it had written so far; one
|
|
// that put that file in place because the input ended would leave the
|
|
// named file.
|
|
func interrupted(
|
|
t *testing.T, signal os.Signal, operation, input string, endInput bool,
|
|
) {
|
|
t.Helper()
|
|
|
|
name := operation + " " + signal.String()
|
|
directory := t.TempDir()
|
|
|
|
command, producer := writing(
|
|
t, directory, input,
|
|
os.Args[0], "age", operation, "-o", filepath.Join(directory, "notes"),
|
|
)
|
|
|
|
require.NoError(t, command.Process.Signal(signal))
|
|
|
|
if endInput {
|
|
require.NoError(t, producer.Close())
|
|
}
|
|
|
|
waitForTool(t, name, command)
|
|
|
|
require.Equal(t, 1, command.ProcessState.ExitCode(), name)
|
|
|
|
left, err := os.ReadDir(directory)
|
|
require.NoError(t, err)
|
|
require.Empty(t, left, name)
|
|
}
|
|
|
|
// writing starts argv, the tool told to write into directory, as a
|
|
// subprocess reading the input from a pipe, and returns once the tool
|
|
// has begun writing the file beside the one it was named. The pipe is
|
|
// left open for the caller to end.
|
|
func writing(
|
|
t *testing.T, directory, input string, argv ...string,
|
|
) (*exec.Cmd, io.WriteCloser) {
|
|
t.Helper()
|
|
|
|
//nolint:gosec // this test's own binary as the tool, or nohup running it
|
|
command := exec.CommandContext(t.Context(), argv[0], argv[1:]...)
|
|
|
|
command.Env = append(os.Environ(), runAsTool+"=1")
|
|
|
|
producer, err := command.StdinPipe()
|
|
require.NoError(t, err)
|
|
require.NoError(t, command.Start())
|
|
|
|
_, err = io.WriteString(producer, input)
|
|
require.NoError(t, err)
|
|
|
|
// The file beside the named one is made once the mnemonic has been
|
|
// read, before any input is.
|
|
require.Eventually(t, func() bool {
|
|
entries, err := os.ReadDir(directory)
|
|
|
|
return err == nil && len(entries) > 0
|
|
}, 5*time.Second, 5*time.Millisecond)
|
|
|
|
return command, producer
|
|
}
|
|
|
|
// written puts the contents in a file of that name in a directory of
|
|
// this test's own and returns the path to it.
|
|
func written(t *testing.T, name, contents string) string {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(t.TempDir(), name)
|
|
require.NoError(t, os.WriteFile(path, []byte(contents), 0o600))
|
|
|
|
return path
|
|
}
|