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:
@@ -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
|
||||
|
||||
+60
-26
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+20
-14
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
+11
-3
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user