Every command ends on SIGINT, SIGTERM or SIGHUP; an interrupted age -o leaves no file (closes #48)
check / check (push) Failing after 1s
check / check (push) Failing after 1s
SIGINT, SIGTERM and SIGHUP are no longer caught for the whole run, so they end any command at once, the mnemonic prompt included. One package, internal/cli/signals, catches them only where cleanup is needed, and only those not ignored at start, so nohup still works: ssh to and ssh install while their child runs, and age encrypt -o and age decrypt -o while they write. A signal received by the time the input ends leaves no new file and exits 1; otherwise the whole file is put in place, never an unfinished one. Judgement call: the guarantee is stated for a signal keyfunc has received, as Go cannot promise more; the main goroutine stays on the main thread so a Ctrl-C on a pipeline is seen first on Linux. Unverified on macOS. Model: opus-5-5 (implementation); fable-5-1 (design)
This commit was merged in pull request #54.
This commit is contained in:
@@ -1,13 +1,21 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/keyfunc/internal/agekey"
|
||||
"sneak.berlin/go/keyfunc/internal/cli"
|
||||
"sneak.berlin/go/keyfunc/internal/cli/age"
|
||||
"sneak.berlin/go/keyfunc/internal/mnemonic"
|
||||
)
|
||||
|
||||
@@ -90,6 +98,256 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASignalReceivedAsTheInputEndsLeavesTheFileAsItWas(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n"))
|
||||
|
||||
for _, ending := range []syscall.Signal{
|
||||
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
|
||||
} {
|
||||
receivedAtTheEnd(t, ending, "encrypt", "the secret\n")
|
||||
receivedAtTheEnd(t, ending, "decrypt", sealed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASignalAsTheInputEndsLeavesNoUnfinishedFile(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 the tool has first varies, so it is tried often, and
|
||||
// a whole file in place is accepted as well as none.
|
||||
for range 25 {
|
||||
named := signalledAsTheInputEnds(t, "encrypt", "the start of the secret\n")
|
||||
if named != "" {
|
||||
require.Equal(t,
|
||||
"the start of the secret\n", run(t, "age", "decrypt", named),
|
||||
)
|
||||
}
|
||||
|
||||
named = signalledAsTheInputEnds(t, "decrypt", sealed)
|
||||
if named != "" {
|
||||
require.Equal(t, "the secret\n", read(t, named))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 and leaves the input 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.
|
||||
func interrupted(t *testing.T, ending os.Signal, operation, input string) {
|
||||
t.Helper()
|
||||
|
||||
name := operation + " " + ending.String()
|
||||
directory := t.TempDir()
|
||||
|
||||
command, _ := writing(
|
||||
t, directory, input,
|
||||
os.Args[0], "age", operation, "-o", filepath.Join(directory, "notes"),
|
||||
)
|
||||
|
||||
require.NoError(t, command.Process.Signal(ending))
|
||||
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)
|
||||
}
|
||||
|
||||
// signalledAsTheInputEnds 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 SIGINT and at once ends its input. Either
|
||||
// the tool ends with status 1 and leaves the directory empty, and ""
|
||||
// is returned, or it ends otherwise and leaves only the named file,
|
||||
// whose path is returned for the caller to check that it is whole.
|
||||
func signalledAsTheInputEnds(t *testing.T, operation, input string) string {
|
||||
t.Helper()
|
||||
|
||||
directory := t.TempDir()
|
||||
named := filepath.Join(directory, "notes")
|
||||
|
||||
command, producer := writing(
|
||||
t, directory, input, os.Args[0], "age", operation, "-o", named,
|
||||
)
|
||||
|
||||
require.NoError(t, command.Process.Signal(syscall.SIGINT))
|
||||
require.NoError(t, producer.Close())
|
||||
waitForTool(t, operation, command)
|
||||
|
||||
left, err := os.ReadDir(directory)
|
||||
require.NoError(t, err)
|
||||
|
||||
if command.ProcessState.ExitCode() == failedStatus {
|
||||
require.Empty(t, left, operation)
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
require.Len(t, left, 1, operation)
|
||||
|
||||
return named
|
||||
}
|
||||
|
||||
// receivedAtTheEnd runs "age encrypt -o" or "age decrypt -o", as the
|
||||
// operation says, in this process, over a file that is already there,
|
||||
// with an input that at its end sends this process the signal and waits
|
||||
// until it has been received. The tool has to return ErrInterrupted and
|
||||
// leave that file as it was, with nothing beside it. A tool that went
|
||||
// by the end of the input alone would put its new file in place.
|
||||
func receivedAtTheEnd(
|
||||
t *testing.T, ending syscall.Signal, operation, input string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
name := operation + " " + ending.String()
|
||||
existing := written(t, "notes", "what was already there\n")
|
||||
|
||||
// The test catches the signal as well, so that it does not end the
|
||||
// test binary and so that the input can wait for it.
|
||||
received := make(chan os.Signal, 1)
|
||||
signal.Notify(received, ending)
|
||||
|
||||
defer signal.Stop(received)
|
||||
|
||||
root := cli.Root()
|
||||
root.SetIn(&endingInASignal{
|
||||
rest: strings.NewReader(input), ending: ending, received: received,
|
||||
})
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"age", operation, "-o", existing})
|
||||
|
||||
err := root.ExecuteContext(t.Context())
|
||||
require.ErrorIs(t, err, age.ErrInterrupted, name)
|
||||
|
||||
require.Equal(t, "what was already there\n", read(t, existing), name)
|
||||
|
||||
left, err := os.ReadDir(filepath.Dir(existing))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, left, 1, name)
|
||||
}
|
||||
|
||||
// endingInASignal is an input that, when it runs out, sends this
|
||||
// process its signal and waits for it on received before it reports its
|
||||
// end. It sends the signal only once: once nothing catches it, another
|
||||
// would end the test binary.
|
||||
type endingInASignal struct {
|
||||
rest io.Reader
|
||||
ending syscall.Signal
|
||||
received chan os.Signal
|
||||
sent bool
|
||||
}
|
||||
|
||||
func (input *endingInASignal) Read(buffer []byte) (int, error) {
|
||||
n, err := input.rest.Read(buffer)
|
||||
if !errors.Is(err, io.EOF) || input.sent {
|
||||
return n, err
|
||||
}
|
||||
|
||||
input.sent = true
|
||||
|
||||
err = syscall.Kill(os.Getpid(), input.ending)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
|
||||
<-input.received
|
||||
|
||||
return n, io.EOF
|
||||
}
|
||||
|
||||
// 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