Write encrypt and decrypt output only once they succeed (closes #143)
check / check (push) Waiting to run
check / check (push) Waiting to run
`secret encrypt` and `secret decrypt` emptied the file named by `--output` before reading any input, so a failure left it empty, and a file given as both `--input` and `--output` was emptied before it was read. They now write into a temporary file of mode 0600 next to it, or next to the file it is a symbolic link to, and rename that over it only on success, with the new `secret.WriteFileAtomicFunc`. An output file that cannot be written still fails. Ctrl-C, SIGTERM or a hangup removes the temporary file. A device or pipe, such as `/dev/null`, is written to directly. Judgement calls: the device and pipe case; a command started with `nohup` still ignores a hangup. Model: opus-5-5
This commit is contained in:
@@ -7,7 +7,9 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -105,3 +107,115 @@ func TestInterruptExitsThroughMemguard(t *testing.T) {
|
||||
require.ErrorAs(t, err, &exitErr)
|
||||
assert.Equal(t, 1, exitErr.ExitCode(), "add ended with %v", err)
|
||||
}
|
||||
|
||||
// Ctrl-C or a hangup while encrypt or decrypt waits for input must remove
|
||||
// the temporary file it is writing next to --output, which for decrypt
|
||||
// holds part of the secret.
|
||||
func TestInterruptRemovesTemporaryOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
||||
defer cancel()
|
||||
|
||||
secretPath := secretBinaryPath(t)
|
||||
env := []string{
|
||||
secret.EnvStateDir + "=" + t.TempDir(),
|
||||
secret.EnvMnemonic + "=" + testMnemonic,
|
||||
secret.EnvUnlockPassphrase + "=test-passphrase",
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
}
|
||||
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
initCmd := exec.CommandContext(ctx, secretPath, "init")
|
||||
initCmd.Env = env
|
||||
|
||||
output, err := initCmd.CombinedOutput()
|
||||
require.NoError(t, err, "init should succeed: %s", output)
|
||||
|
||||
// encrypt stores the new key before it starts writing, so decrypt has it
|
||||
for _, command := range []string{"encrypt", "decrypt"} {
|
||||
for _, sig := range []os.Signal{os.Interrupt, syscall.SIGHUP} {
|
||||
outputDir := t.TempDir()
|
||||
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(ctx, secretPath, command, "key",
|
||||
"--output", filepath.Join(outputDir, "out"))
|
||||
cmd.Env = env
|
||||
|
||||
// Held open and never written, so the command keeps waiting for
|
||||
// input. Wait closes it.
|
||||
_, err := cmd.StdinPipe()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, cmd.Start())
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
entries, err := os.ReadDir(outputDir)
|
||||
|
||||
return err == nil && len(entries) > 0
|
||||
}, commandWait, 10*time.Millisecond, "%s made no temporary file", command)
|
||||
require.NoError(t, cmd.Process.Signal(sig))
|
||||
|
||||
var exitErr *exec.ExitError
|
||||
|
||||
require.ErrorAs(t, cmd.Wait(), &exitErr)
|
||||
|
||||
entries, err := os.ReadDir(outputDir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries, "%s left its temporary file on %v", command, sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// encrypt started with nohup must go on ignoring a hangup, which it catches
|
||||
// otherwise, and finish writing --output.
|
||||
func TestNohupIgnoresHangup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
||||
defer cancel()
|
||||
|
||||
secretPath := secretBinaryPath(t)
|
||||
env := []string{
|
||||
secret.EnvStateDir + "=" + t.TempDir(),
|
||||
secret.EnvMnemonic + "=" + testMnemonic,
|
||||
secret.EnvUnlockPassphrase + "=test-passphrase",
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
}
|
||||
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
initCmd := exec.CommandContext(ctx, secretPath, "init")
|
||||
initCmd.Env = env
|
||||
|
||||
output, err := initCmd.CombinedOutput()
|
||||
require.NoError(t, err, "init should succeed: %s", output)
|
||||
|
||||
outputDir := t.TempDir()
|
||||
outputFile := filepath.Join(outputDir, "out")
|
||||
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(ctx, "nohup", secretPath, "encrypt", "key",
|
||||
"--output", outputFile)
|
||||
cmd.Env = env
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, cmd.Start())
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
entries, err := os.ReadDir(outputDir)
|
||||
|
||||
return err == nil && len(entries) > 0
|
||||
}, commandWait, 10*time.Millisecond, "encrypt made no temporary file")
|
||||
require.NoError(t, cmd.Process.Signal(syscall.SIGHUP))
|
||||
|
||||
// Time for a hangup that is caught to end encrypt before it has its input
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
_, err = stdin.Write([]byte("document"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, stdin.Close())
|
||||
require.NoError(t, cmd.Wait())
|
||||
|
||||
_, err = os.Stat(outputFile)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user