Signals end every command, not only ssh to and ssh install (closes #48)
check / check (push) Failing after 3s
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
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
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"
|
||||
@@ -90,6 +94,140 @@ func TestARefusedDecryptionLeavesTheOutputFileAlone(t *testing.T) {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user