Signals end every command, not only ssh to and ssh install (closes #48)
check / check (push) Successful in 2m35s
check / check (push) Successful in 2m35s
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` went on to put the encryption of the cut-off input in place. Now only `ssh to` and `ssh install` catch them, from once the mnemonic is read until their cleanup has run; everywhere else they end the tool at once. Tests cover an interrupted `age encrypt -o` and the install working directory on a signal. Model: opus-5-5
This commit is contained in:
@@ -274,6 +274,15 @@ 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
|
Errors go to standard error and the exit status is 1, except for `ssh to`, which
|
||||||
passes through `ssh`'s own exit status.
|
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).
|
||||||
|
An interrupted `age encrypt -o` or `age decrypt -o` leaves the named file as it
|
||||||
|
was; the unfinished file it was writing stays beside it, named
|
||||||
|
`<file>.<digits>`. 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
|
## Entrypoints
|
||||||
|
|
||||||
The repo adheres to the
|
The repo adheres to the
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package cli_test
|
package cli_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/keyfunc/internal/agekey"
|
"sneak.berlin/go/keyfunc/internal/agekey"
|
||||||
@@ -90,6 +94,58 @@ func TestARefusedDecryptionLeavesTheOutputFileAlone(t *testing.T) {
|
|||||||
require.Equal(t, "what was already there\n", string(kept))
|
require.Equal(t, "what was already there\n", string(kept))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestASignalStopsAnEncryptionAndPutsNoFileInPlace(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
for _, ending := range []os.Signal{
|
||||||
|
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
|
||||||
|
} {
|
||||||
|
encryptionInterrupted(t, ending.String(), ending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptionInterrupted runs "age encrypt -o" as a subprocess reading
|
||||||
|
// from a pipe that stays open, waits until the tool has begun writing
|
||||||
|
// the file beside the one it was named, and sends it the signal. The
|
||||||
|
// tool has to end on the signal alone, with a failure, and leave
|
||||||
|
// nothing at the name it was given. A tool that went on reading would
|
||||||
|
// not end until the input did, and would then put the encryption of the
|
||||||
|
// cut-off input in place.
|
||||||
|
func encryptionInterrupted(t *testing.T, name string, signal os.Signal) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
directory := t.TempDir()
|
||||||
|
sealed := filepath.Join(directory, "notes.age")
|
||||||
|
|
||||||
|
//nolint:gosec // the binary is this test's own, re-run as the tool
|
||||||
|
command := exec.CommandContext(
|
||||||
|
t.Context(), os.Args[0], "age", "encrypt", "-o", sealed,
|
||||||
|
)
|
||||||
|
|
||||||
|
command.Env = append(os.Environ(), runAsTool+"=1")
|
||||||
|
|
||||||
|
producer, err := command.StdinPipe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, command.Start())
|
||||||
|
|
||||||
|
_, err = io.WriteString(producer, "the start of the secret\n")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The file beside the named one is made once the mnemonic has been
|
||||||
|
// read, just before the encryption starts.
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
entries, err := os.ReadDir(directory)
|
||||||
|
|
||||||
|
return err == nil && len(entries) > 0
|
||||||
|
}, 5*time.Second, 5*time.Millisecond)
|
||||||
|
|
||||||
|
require.NoError(t, command.Process.Signal(signal))
|
||||||
|
waitForTool(t, name, command)
|
||||||
|
|
||||||
|
require.False(t, command.ProcessState.Success(), name)
|
||||||
|
require.NoFileExists(t, sealed, name)
|
||||||
|
}
|
||||||
|
|
||||||
// written puts the contents in a file of that name in a directory of
|
// written puts the contents in a file of that name in a directory of
|
||||||
// this test's own and returns the path to it.
|
// this test's own and returns the path to it.
|
||||||
func written(t *testing.T, name, contents string) string {
|
func written(t *testing.T, name, contents string) string {
|
||||||
|
|||||||
+6
-14
@@ -2,13 +2,10 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"sneak.berlin/go/keyfunc/internal/cli/age"
|
"sneak.berlin/go/keyfunc/internal/cli/age"
|
||||||
@@ -70,18 +67,13 @@ func Root() *cobra.Command {
|
|||||||
// ended with. ssh has already said whatever it had to say in that
|
// ended with. ssh has already said whatever it had to say in that
|
||||||
// case, so nothing more is printed.
|
// case, so nothing more is printed.
|
||||||
//
|
//
|
||||||
// SIGINT, SIGTERM and SIGHUP cancel the command's context instead of
|
// SIGINT, SIGTERM and SIGHUP end the tool at once, as they end any Go
|
||||||
// killing the process outright, so the child ssh or sftp ends and the
|
// program, so a command waiting at the mnemonic prompt or reading what
|
||||||
// deferred cleanup that removes the agent socket and the install
|
// it encrypts or decrypts goes no further. The exception is "ssh to"
|
||||||
// working directory still runs.
|
// and "ssh install" while they have ssh or sftp running: they catch the
|
||||||
|
// signals there, so the child ends and their own cleanup still runs.
|
||||||
func Main() int {
|
func Main() int {
|
||||||
ctx, stop := signal.NotifyContext(
|
err := Root().Execute()
|
||||||
context.Background(),
|
|
||||||
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
|
|
||||||
)
|
|
||||||
defer stop()
|
|
||||||
|
|
||||||
err := Root().ExecuteContext(ctx)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -55,6 +57,17 @@ func install() *cobra.Command {
|
|||||||
return err
|
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 := signal.NotifyContext(
|
||||||
|
cmd.Context(),
|
||||||
|
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
|
||||||
|
)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
cmd.SetContext(ctx)
|
||||||
|
|
||||||
return add(cmd, args[0], args[1:], line)
|
return add(cmd, args[0], args[1:], line)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-3
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"os/signal"
|
||||||
"slices"
|
"slices"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
@@ -42,7 +43,17 @@ func to() *cobra.Command {
|
|||||||
return err
|
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 := signal.NotifyContext(
|
||||||
|
cmd.Context(),
|
||||||
|
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
|
||||||
|
)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
served, err := key.Serve(ctx, comment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -53,7 +64,7 @@ func to() *cobra.Command {
|
|||||||
"-o", "IdentityAgent=" + served.Socket(),
|
"-o", "IdentityAgent=" + served.Socket(),
|
||||||
}, args)
|
}, args)
|
||||||
|
|
||||||
return connect(cmd.Context(), argv)
|
return connect(ctx, argv)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +87,7 @@ func connect(ctx context.Context, argv []string) error {
|
|||||||
command.Stdout = os.Stdout
|
command.Stdout = os.Stdout
|
||||||
command.Stderr = os.Stderr
|
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
|
// SIGTERM rather than the default kill, so it puts the terminal
|
||||||
// back the way it found it before it goes.
|
// back the way it found it before it goes.
|
||||||
command.Cancel = func() error {
|
command.Cancel = func() error {
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ import (
|
|||||||
|
|
||||||
// runAsTool, set in the environment of a re-executed test binary, tells
|
// 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
|
// 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 tests can drive the real signal path in a process they can
|
||||||
// signal to.
|
// send a signal to.
|
||||||
const runAsTool = "KEYFUNC_TEST_RUN_AS_TOOL"
|
const runAsTool = "KEYFUNC_TEST_RUN_AS_TOOL"
|
||||||
|
|
||||||
// TestMain re-executes the test binary as the tool when runAsTool is
|
// TestMain re-executes the test binary as the tool when runAsTool is
|
||||||
// set, and otherwise runs the suite. The signal test starts the tool
|
// set, and otherwise runs the suite. The signal tests start the tool
|
||||||
// this way, as a subprocess it can signal and watch clean up.
|
// this way, as a subprocess they can signal and watch end.
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
if os.Getenv(runAsTool) == "1" {
|
if os.Getenv(runAsTool) == "1" {
|
||||||
os.Exit(cli.Main())
|
os.Exit(cli.Main())
|
||||||
@@ -201,6 +201,16 @@ fi
|
|||||||
sleep 5
|
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.
|
// pretended is where a stand-in writes down what it was asked to do.
|
||||||
type pretended struct {
|
type pretended struct {
|
||||||
// home stands in for the home directory on the host.
|
// home stands in for the home directory on the host.
|
||||||
@@ -510,7 +520,7 @@ func TestASignalTakesTheAgentDirectoryDown(t *testing.T) {
|
|||||||
// ssh that blocks, waits until the agent is up and ssh is running
|
// 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
|
// 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
|
// 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,
|
// removed the signal kills the tool outright, no deferred cleanup runs,
|
||||||
// the directory is left behind, and the check fails.
|
// the directory is left behind, and the check fails.
|
||||||
func signalEndsTheTool(t *testing.T, name string, signal os.Signal) {
|
func signalEndsTheTool(t *testing.T, name string, signal os.Signal) {
|
||||||
@@ -577,6 +587,56 @@ func waitForSocket(t *testing.T, noted string) string {
|
|||||||
return socket
|
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) {
|
func TestTheMnemonicIsNotHandedToSFTP(t *testing.T) {
|
||||||
t.Setenv(mnemonic.CommandVariable, "echo "+example())
|
t.Setenv(mnemonic.CommandVariable, "echo "+example())
|
||||||
t.Setenv(mnemonic.Variable, example())
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|||||||
Reference in New Issue
Block a user