From c96b77dd672d9c2dc1ffacec6de1a4837fa27186 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 13:42:49 +0200 Subject: [PATCH] Every command ends on SIGINT, SIGTERM or SIGHUP; an interrupted age -o leaves no file (closes #48) 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) --- README.md | 17 ++- internal/cli/age/age.go | 86 +++++++---- internal/cli/age_test.go | 258 ++++++++++++++++++++++++++++++++ internal/cli/cli.go | 34 +++-- internal/cli/signals/signals.go | 53 +++++++ internal/cli/ssh/install.go | 9 ++ internal/cli/ssh/to.go | 14 +- internal/cli/ssh_test.go | 70 ++++++++- 8 files changed, 491 insertions(+), 50 deletions(-) create mode 100644 internal/cli/signals/signals.go diff --git a/README.md b/README.md index 98a53ba..2fecc6c 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,9 @@ calls into `internal/`. The packages there are: - `internal/childmnemonic` derives a child mnemonic from the main one using BIP-85's own mnemonic application. - `internal/cli` builds the cobra command tree and runs it. Under it, - `cli/options` holds the flags every command shares, and `cli/ssh`, `cli/age` - and `cli/mnemonic` are the command groups. + `cli/options` holds the flags every command shares, `cli/signals` catches + SIGINT, SIGTERM and SIGHUP for the commands that clean up before they end, and + `cli/ssh`, `cli/age` and `cli/mnemonic` are the command groups. ### Adding a key type @@ -288,6 +289,18 @@ girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose Errors go to standard error and the exit status is 1, except for `ssh to`, which passes through `ssh`'s own exit status. +SIGINT, SIGTERM and SIGHUP end any command at once, at the mnemonic prompt too, +with the status a shell gives a program killed by that signal (130 for SIGINT). +While `age encrypt -o` or `age decrypt -o` is writing the file, the signal makes +it remove the unfinished file, leave a file already at the named path as it was, +and exit with status 1. That holds for a signal that has reached `keyfunc` when +its input ends; a later one leaves the whole file in place. Ctrl-C on a pipeline +ends the input at the same moment, and on Linux `keyfunc` sees the signal first, +though no system promises that. While `ssh to` or `ssh install` has `ssh` or +`sftp` running, the signal ends that program instead, the tool removes its agent +socket or working files, and it exits with status 1, or for `ssh to` with +`ssh`'s own status if `ssh` reported one. + ## Entrypoints The repo adheres to the diff --git a/internal/cli/age/age.go b/internal/cli/age/age.go index 700fdf2..b9d4609 100644 --- a/internal/cli/age/age.go +++ b/internal/cli/age/age.go @@ -3,17 +3,27 @@ package age import ( + "context" + "errors" "fmt" "io" "os" + "os/signal" "path/filepath" "github.com/spf13/cobra" "sneak.berlin/go/keyfunc/internal/agekey" "sneak.berlin/go/keyfunc/internal/cli/options" + "sneak.berlin/go/keyfunc/internal/cli/signals" "sneak.berlin/go/keyfunc/internal/derive" ) +// ErrInterrupted is returned when SIGINT, SIGTERM or SIGHUP has been +// received by the time the work writing the file --output names ends. +var ErrInterrupted = errors.New( + "interrupted by a signal; the output file was left as it was", +) + // Command returns the age command and everything under it. func Command() *cobra.Command { group := &cobra.Command{ @@ -128,8 +138,9 @@ func runDecrypt(cmd *cobra.Command, args []string) error { return through(cmd, args, key.Decrypt) } -// through opens the input and the output the arguments ask for, hands -// them to the work, and finishes the output afterwards either way. +// through opens the input the arguments ask for and hands it to the +// work, with the file --output names to write to, or the command's own +// output when it names none. func through( cmd *cobra.Command, args []string, work func(io.Writer, io.Reader) error, @@ -141,14 +152,16 @@ func through( defer closeSrc() - dst, done, err := output(cmd) + name, err := cmd.Flags().GetString("output") if err != nil { - return err + return fmt.Errorf("reading the output file: %w", err) } - err = work(dst, src) + if name == "" { + return work(cmd.OutOrStdout(), src) + } - return done(err) + return output(name, src, work) } // input returns what to read from: the named file, or the command's @@ -167,35 +180,56 @@ func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) { return file, func() { _ = file.Close() }, nil } -// output returns what to write to: a new file beside the one --output -// names, or the command's own output when it names none. The second -// result finishes the write, and is given whatever the work returned: -// the new file takes the named file's place only when the work -// succeeded, so a file that is already there survives a run that -// failed. -func output(cmd *cobra.Command) (io.Writer, func(error) error, error) { - name, err := cmd.Flags().GetString("output") - if err != nil { - return nil, nil, fmt.Errorf("reading the output file: %w", err) - } +// output has the work write a new file beside the named one, and puts +// the new file in the named file's place only when the work succeeded, +// so a file that is already there survives a run that failed. +// +// Meanwhile SIGINT, SIGTERM and SIGHUP are caught, as signals.Context +// does. One the tool has received by the time the work ends wins: the +// new file is removed and ErrInterrupted returned, at once if the work +// is still running, without waiting for it, since it may be blocked +// reading its input. +func output( + name string, src io.Reader, work func(io.Writer, io.Reader) error, +) error { + // received is registered before the context, so it gets every + // signal the context gets. + received := make(chan os.Signal, 1) + signals.Notify(received) - if name == "" { - return cmd.OutOrStdout(), func(failed error) error { - return failed - }, nil - } + defer signal.Stop(received) + + // The context goes on catching the signals until the file is in + // place or removed, so that a later one cannot end the tool with + // the new file left beside the named one. + interrupted, stop := signals.Context(context.Background()) + defer stop() // The file is made in the same directory so that putting it in // place is a rename and never a copy, and it is readable only by // its owner, which is the mode it keeps once renamed. file, err := os.CreateTemp(filepath.Dir(name), filepath.Base(name)+".") if err != nil { - return nil, nil, fmt.Errorf("creating a file beside %s: %w", name, err) + return fmt.Errorf("creating a file beside %s: %w", name, err) } - return file, func(failed error) error { - return finish(file, name, failed) - }, nil + worked := make(chan error, 1) + + go func() { worked <- work(file, src) }() + + select { + case failed := <-worked: + // Stop returns only once every signal the tool has received + // has been handed over, so an empty received means none came. + signal.Stop(received) + + if len(received) == 0 { + return finish(file, name, failed) + } + case <-interrupted.Done(): + } + + return finish(file, name, ErrInterrupted) } // finish closes the new file and puts it in the named file's place, or diff --git a/internal/cli/age_test.go b/internal/cli/age_test.go index 1b37231..2209db9 100644 --- a/internal/cli/age_test.go +++ b/internal/cli/age_test.go @@ -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 { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 37ee347..7824e68 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -2,13 +2,11 @@ package cli import ( - "context" "errors" "fmt" "os" - "os/signal" + "runtime" "runtime/debug" - "syscall" "github.com/spf13/cobra" "sneak.berlin/go/keyfunc/internal/cli/age" @@ -64,24 +62,32 @@ func Root() *cobra.Command { return root } +// init keeps the command on the main thread. Linux hands a signal sent +// to the tool to that thread first, and a thread runs a pending signal +// handler before its own code, so when "age encrypt -o" or "age +// decrypt -o" checks for a signal as its input ends, one sent before +// then, as by Ctrl-C on a pipeline, has been received. +// +//nolint:gochecknoinits // only an init can keep main on the main thread +func init() { + runtime.LockOSThread() +} + // Main runs the tool and returns the status the process should exit // with. An error ends the tool with status 1, except when it carries a // status of its own, which "ssh to" uses to hand on the status ssh // ended with. ssh has already said whatever it had to say in that // case, so nothing more is printed. // -// SIGINT, SIGTERM and SIGHUP cancel the command's context instead of -// killing the process outright, so the child ssh or sftp ends and the -// deferred cleanup that removes the agent socket and the install -// working directory still runs. +// SIGINT, SIGTERM and SIGHUP end the tool at once, as they end any Go +// program, so a command waiting at the mnemonic prompt or reading what +// it encrypts or decrypts goes no further. The exceptions catch the +// signals to clean up first: "ssh to" and "ssh install" while they +// have ssh or sftp running, so the child ends and their own cleanup +// still runs, and "age encrypt -o" and "age decrypt -o" while they +// write, so the unfinished file is removed. func Main() int { - ctx, stop := signal.NotifyContext( - context.Background(), - syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, - ) - defer stop() - - err := Root().ExecuteContext(ctx) + err := Root().Execute() if err == nil { return 0 } diff --git a/internal/cli/signals/signals.go b/internal/cli/signals/signals.go new file mode 100644 index 0000000..1657564 --- /dev/null +++ b/internal/cli/signals/signals.go @@ -0,0 +1,53 @@ +// Package signals catches the signals that end the tool, for the +// commands that clean up before they end. +package signals + +import ( + "context" + "os" + "os/signal" + "syscall" +) + +// Context is signal.NotifyContext for SIGINT, SIGTERM and SIGHUP: the +// context it returns is cancelled when one of them arrives, and stop +// stops catching them. It leaves out any of the three the tool was +// started with set to be ignored, as nohup does with SIGHUP, because +// catching a signal turns an ignored one back on and would end a run +// that was meant to survive it. +func Context(parent context.Context) (context.Context, context.CancelFunc) { + endings := caught() + + // Given no signals at all, NotifyContext would catch every one. + if len(endings) == 0 { + return context.WithCancel(parent) + } + + return signal.NotifyContext(parent, endings...) +} + +// Notify is signal.Notify for the signals Context catches: each one +// that arrives is sent to c, until signal.Stop(c). +func Notify(c chan<- os.Signal) { + endings := caught() + + // Given no signals at all, Notify would catch every one. + if len(endings) > 0 { + signal.Notify(c, endings...) + } +} + +// caught returns those of SIGINT, SIGTERM and SIGHUP that the tool was +// not started with set to be ignored. +func caught() []os.Signal { + endings := []os.Signal{syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP} + kept := make([]os.Signal, 0, len(endings)) + + for _, ending := range endings { + if !signal.Ignored(ending) { + kept = append(kept, ending) + } + } + + return kept +} diff --git a/internal/cli/ssh/install.go b/internal/cli/ssh/install.go index 162bd23..263ed45 100644 --- a/internal/cli/ssh/install.go +++ b/internal/cli/ssh/install.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/spf13/cobra" + "sneak.berlin/go/keyfunc/internal/cli/signals" ) // Where the key goes on the host and what the file it arrives in is @@ -62,6 +63,14 @@ func install() *cobra.Command { return err } + // From here on a signal cancels the context, which + // sftp runs under, instead of ending the tool, so sftp + // ends and the working directory is still removed. + ctx, stop := signals.Context(cmd.Context()) + defer stop() + + cmd.SetContext(ctx) + return add(cmd, args[0], args[1:], line) }, } diff --git a/internal/cli/ssh/to.go b/internal/cli/ssh/to.go index c36ec88..c165796 100644 --- a/internal/cli/ssh/to.go +++ b/internal/cli/ssh/to.go @@ -10,6 +10,7 @@ import ( "syscall" "github.com/spf13/cobra" + "sneak.berlin/go/keyfunc/internal/cli/signals" ) // StatusError says the tool should end with the status ssh ended with. @@ -42,7 +43,14 @@ func to() *cobra.Command { return err } - served, err := key.Serve(cmd.Context(), comment) + // From here until the agent is taken down, a signal + // cancels the context instead of ending the tool, so + // ssh ends and the socket and its directory are still + // removed. + ctx, stop := signals.Context(cmd.Context()) + defer stop() + + served, err := key.Serve(ctx, comment) if err != nil { return err } @@ -53,7 +61,7 @@ func to() *cobra.Command { "-o", "IdentityAgent=" + served.Socket(), }, args) - return connect(cmd.Context(), argv) + return connect(ctx, argv) }, } @@ -76,7 +84,7 @@ func connect(ctx context.Context, argv []string) error { command.Stdout = os.Stdout command.Stderr = os.Stderr - // A cancelled context means a signal ended the tool. Send ssh a + // A cancelled context means a signal arrived. Send ssh a // SIGTERM rather than the default kill, so it puts the terminal // back the way it found it before it goes. command.Cancel = func() error { diff --git a/internal/cli/ssh_test.go b/internal/cli/ssh_test.go index 04749f4..4776237 100644 --- a/internal/cli/ssh_test.go +++ b/internal/cli/ssh_test.go @@ -20,13 +20,13 @@ import ( // runAsTool, set in the environment of a re-executed test binary, tells // TestMain to run the tool through Main rather than the suite, so the -// signal test can drive the real signal path in a process it can send a -// signal to. +// signal tests can drive the real signal path in a process they can +// send a signal to. const runAsTool = "KEYFUNC_TEST_RUN_AS_TOOL" // TestMain re-executes the test binary as the tool when runAsTool is -// set, and otherwise runs the suite. The signal test starts the tool -// this way, as a subprocess it can signal and watch clean up. +// set, and otherwise runs the suite. The signal tests start the tool +// this way, as a subprocess they can signal and watch end. func TestMain(m *testing.M) { if os.Getenv(runAsTool) == "1" { os.Exit(cli.Main()) @@ -209,6 +209,16 @@ fi sleep 5 ` +// stalled is a stand-in for the system sftp that notes it has started +// and then blocks, so a test can signal the tool while sftp is running +// and watch it remove its working directory. The exec keeps the shell +// from leaving a sleep behind that holds the output the tool reads sftp +// through. +const stalled = ` +touch "$KEYFUNC_TEST_STARTED" +exec sleep 5 +` + // pretended is where a stand-in writes down what it was asked to do. type pretended struct { // home stands in for the home directory on the host. @@ -544,7 +554,7 @@ func TestASignalTakesTheAgentDirectoryDown(t *testing.T) { // ssh that blocks, waits until the agent is up and ssh is running // against it, sends the tool the signal, and requires the agent socket // and its directory to be gone once the tool has ended. The subprocess -// goes through Main and its signal handling, so with that handling +// goes through Main and the command's signal handling, so with that handling // removed the signal kills the tool outright, no deferred cleanup runs, // the directory is left behind, and the check fails. func signalEndsTheTool(t *testing.T, name string, signal os.Signal) { @@ -611,6 +621,56 @@ func waitForSocket(t *testing.T, noted string) string { return socket } +func TestASignalTakesTheInstallWorkingDirectoryDown(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + for _, ending := range []os.Signal{ + syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, + } { + signalEndsTheInstall(t, ending.String(), ending) + } +} + +// signalEndsTheInstall runs "ssh install" as a subprocess against a +// stand-in sftp that blocks, with a temporary directory of the test's +// own, waits until sftp is running, sends the tool the signal, and +// requires the working directory the tool made there to be gone once +// the tool has ended. +func signalEndsTheInstall(t *testing.T, name string, signal os.Signal) { + t.Helper() + + temporary := t.TempDir() + started := filepath.Join(t.TempDir(), "started") + t.Setenv("KEYFUNC_TEST_STARTED", started) + standIn(t, "sftp", stalled) + + //nolint:gosec // the binary is this test's own, re-run as the tool + command := exec.CommandContext( + t.Context(), os.Args[0], subcommand, installing, host, + ) + + command.Env = append(os.Environ(), runAsTool+"=1", "TMPDIR="+temporary) + require.NoError(t, command.Start()) + + // sftp is started only once the working directory has been made. + require.Eventually(t, func() bool { + _, err := os.Stat(started) + + return err == nil + }, 5*time.Second, 5*time.Millisecond) + + working, err := os.ReadDir(temporary) + require.NoError(t, err) + require.Len(t, working, 1, name) + + require.NoError(t, command.Process.Signal(signal)) + waitForTool(t, name, command) + + left, err := os.ReadDir(temporary) + require.NoError(t, err) + require.Empty(t, left, name) +} + func TestTheMnemonicIsNotHandedToSFTP(t *testing.T) { t.Setenv(mnemonic.CommandVariable, "echo "+example()) t.Setenv(mnemonic.Variable, example())