1 Commits
Author SHA1 Message Date
sneak accfd5878f Write encrypt and decrypt output only once they succeed (closes #143)
check / check (push) Canceled after 0s
`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, using the new `secret.WriteFileAtomicFunc`, which
`secret.WriteFileAtomic` now calls. Ctrl-C or SIGTERM removes the
temporary file. A device or pipe given as `--output`, such as
`/dev/null`, is written to directly.

Judgement call: the device and pipe case goes beyond the plan.
Judgement call: a link to a file that does not exist yet is replaced.

Model: opus-5-5
2026-10-14 18:28:40 +00:00
7 changed files with 186 additions and 30 deletions
+6 -5
View File
@@ -275,11 +275,12 @@ generates a new Age key.
Decrypts data using an Age key stored as a secret. Decrypts data using an Age key stored as a secret.
Both write the result to a new file next to `--output` and rename it over Both write the result to a new file next to `--output` and rename it over
`--output` only once they have succeeded, so a failed command leaves an existing `--output` only once they have succeeded, so a failed or interrupted command
`--output` as it was and creates none, and `--input` and `--output` can be the leaves an existing `--output` as it was and creates none, and `--input` and
same file. The file has mode 0600, whatever mode the file it replaced had, and `--output` can be the same file. The file has mode 0600, whatever mode the file
when `--output` is a symbolic link to a file, the link itself is replaced. A it replaced had. When `--output` is a symbolic link, the file it points to is
device or a pipe, such as `/dev/null`, is written to directly. replaced and the link kept. A device or a pipe, such as `/dev/null`, is written
to directly.
## Storage Architecture ## Storage Architecture
+6 -4
View File
@@ -23,10 +23,12 @@ https://git.eeqj.de/sneak/secret/milestone/12
`--output` (https://git.eeqj.de/sneak/secret/issues/143). Both emptied the `--output` (https://git.eeqj.de/sneak/secret/issues/143). Both emptied the
output file before reading any input, so a wrong key left it empty, and a file output file before reading any input, so a wrong key left it empty, and a file
given as both was emptied before it was read. They now write the result into a given as both was emptied before it was read. They now write the result into a
temporary file next to it and rename that over it once they have succeeded, temporary file next to it, or next to the file it is a symbolic link to, and
with the new `secret.WriteFileAtomicFunc`, which `secret.WriteFileAtomic` now rename that over it once they have succeeded, with the new
calls; the output file has mode 0600. A device or a pipe given as `--output`, `secret.WriteFileAtomicFunc`, which `secret.WriteFileAtomic` now calls; the
such as `/dev/null`, is still written to directly. output file has mode 0600. Ctrl-C or SIGTERM removes the temporary file. A
device or a pipe given as `--output`, such as `/dev/null`, is still written to
directly.
- 2026-10-12: A mnemonic typed with extra spaces, tabs or line breaks gives the - 2026-10-12: A mnemonic typed with extra spaces, tabs or line breaks gives the
same long-term key as its words separated by single spaces same long-term key as its words separated by single spaces
(https://git.eeqj.de/sneak/secret/issues/135). The key was derived from the (https://git.eeqj.de/sneak/secret/issues/135). The key was derived from the
+31 -4
View File
@@ -5,9 +5,12 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath"
"sync/atomic"
"filippo.io/age" "filippo.io/age"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret" "sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault" "sneak.berlin/go/secret/internal/vault"
@@ -281,11 +284,18 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
}) })
} }
// temporaryOutput is the name of the temporary file writeOutput is filling,
// until it is renamed into place. The signal handler in Entry removes it,
// since for decrypt it holds part of the secret.
//
//nolint:gochecknoglobals // read by the signal handler, which has no Instance
var temporaryOutput atomic.Pointer[string]
// writeOutput calls write with stdout when outputFile is empty, and with // writeOutput calls write with stdout when outputFile is empty, and with
// outputFile itself when it is a device or a pipe, such as /dev/null. // outputFile itself when it is a device or a pipe, such as /dev/null.
// Otherwise write fills a new file of mode 0600 that replaces outputFile // Otherwise write fills a new file of mode 0600 that replaces outputFile,
// only once write has succeeded, so a failure leaves outputFile as it was, // or the file it is a symbolic link to, only once write has succeeded, so a
// and outputFile may be the input file too. // failure leaves that file as it was, and it may be the input file too.
func (cli *Instance) writeOutput( func (cli *Instance) writeOutput(
outputFile string, write func(output io.Writer) error, outputFile string, write func(output io.Writer) error,
) error { ) error {
@@ -304,7 +314,24 @@ func (cli *Instance) writeOutput(
return write(file) return write(file)
} }
return secret.WriteFileAtomicFunc(cli.fs, outputFile, write) // Replace the file a symbolic link points to and keep the link, as
// writing through the link would; /dev/stdout with stdout going to a
// file is such a link. A path that does not exist yet is used as given.
path, err := filepath.EvalSymlinks(outputFile)
if err != nil {
path = outputFile
}
err = secret.WriteFileAtomicFunc(cli.fs, path, func(file afero.File) error {
name := file.Name()
temporaryOutput.Store(&name)
return write(file)
})
temporaryOutput.Store(nil)
return err
} }
// isValidAgeSecretKey checks if a string is a valid age secret key by // isValidAgeSecretKey checks if a string is a valid age secret key by
+59 -3
View File
@@ -2,8 +2,10 @@
package cli package cli
import ( import (
"io"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"syscall" "syscall"
"testing" "testing"
@@ -44,7 +46,8 @@ func newCryptoTest(t *testing.T) (*Instance, string) {
// TestFailedCryptoLeavesOutputUnchanged is a regression test for // TestFailedCryptoLeavesOutputUnchanged is a regression test for
// https://git.eeqj.de/sneak/secret/issues/143, where secret encrypt and // https://git.eeqj.de/sneak/secret/issues/143, where secret encrypt and
// secret decrypt emptied the file named by --output before they failed. // secret decrypt emptied the file named by --output before they failed.
// A failed command must leave an existing file as it was and create none. // A failed command must leave an existing file as it was and create none,
// and give the same error as without --output.
func TestFailedCryptoLeavesOutputUnchanged(t *testing.T) { func TestFailedCryptoLeavesOutputUnchanged(t *testing.T) {
t.Parallel() t.Parallel()
@@ -70,8 +73,13 @@ func TestFailedCryptoLeavesOutputUnchanged(t *testing.T) {
existing := filepath.Join(dir, "existing.txt") existing := filepath.Join(dir, "existing.txt")
require.NoError(t, afero.WriteFile(cli.fs, existing, []byte("precious"), 0o600)) require.NoError(t, afero.WriteFile(cli.fs, existing, []byte("precious"), 0o600))
require.Error(t, tt.run(cli, dir, existing)) cli.cmd.SetOut(io.Discard)
require.Error(t, tt.run(cli, dir, filepath.Join(dir, "new.txt"))) toStdout := tt.run(cli, dir, "")
require.Error(t, toStdout)
require.EqualError(t, tt.run(cli, dir, existing), toStdout.Error())
require.EqualError(t, tt.run(cli, dir, filepath.Join(dir, "new.txt")),
toStdout.Error())
content, err := afero.ReadFile(cli.fs, existing) content, err := afero.ReadFile(cli.fs, existing)
require.NoError(t, err) require.NoError(t, err)
@@ -133,3 +141,51 @@ func TestDecryptToPipe(t *testing.T) {
require.Equal(t, os.ModeNamedPipe, info.Mode().Type()) require.Equal(t, os.ModeNamedPipe, info.Mode().Type())
require.Equal(t, plainText, string(<-received)) require.Equal(t, plainText, string(<-received))
} }
// TestDecryptThroughLink checks that secret decrypt given a symbolic link
// as --output replaces the file the link points to, in another directory,
// and keeps the link, as writing through the link would.
func TestDecryptThroughLink(t *testing.T) {
t.Parallel()
cli, dir := newCryptoTest(t)
targetDir := t.TempDir()
target := filepath.Join(targetDir, "target.txt")
require.NoError(t, afero.WriteFile(cli.fs, target, []byte("old"), 0o600))
link := filepath.Join(dir, "link")
require.NoError(t, os.Symlink(target, link))
require.NoError(t, cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"), link))
info, err := os.Lstat(link)
require.NoError(t, err)
require.Equal(t, os.ModeSymlink, info.Mode().Type())
content, err := afero.ReadFile(cli.fs, target)
require.NoError(t, err)
require.Equal(t, plainText, string(content))
// No temporary file left beside the target
entries, err := afero.ReadDir(cli.fs, targetDir)
require.NoError(t, err)
require.Len(t, entries, 1)
}
// TestDecryptToLongestName checks that secret decrypt writes an --output
// whose name is as long as a file name can be, 255 bytes on Linux and
// macOS: the temporary file's name must not be longer.
func TestDecryptToLongestName(t *testing.T) {
t.Parallel()
const longestName = 255
cli, dir := newCryptoTest(t)
output := filepath.Join(dir, strings.Repeat("a", longestName))
require.NoError(t, cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"), output))
content, err := afero.ReadFile(cli.fs, output)
require.NoError(t, err)
require.Equal(t, plainText, string(content))
}
+57
View File
@@ -8,6 +8,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -105,3 +106,59 @@ func TestInterruptExitsThroughMemguard(t *testing.T) {
require.ErrorAs(t, err, &exitErr) require.ErrorAs(t, err, &exitErr)
assert.Equal(t, 1, exitErr.ExitCode(), "add ended with %v", err) assert.Equal(t, 1, exitErr.ExitCode(), "add ended with %v", err)
} }
// Ctrl-C 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=/usr/bin:/bin",
}
//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"} {
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(os.Interrupt))
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", command)
}
}
+7
View File
@@ -19,6 +19,8 @@ func Entry() int {
// off until the read finishes, so a signal there would leave echo off. // off until the read finishes, so a signal there would leave echo off.
// Only a process in the terminal's foreground process group may reset // Only a process in the terminal's foreground process group may reset
// it: one in the background that tries is stopped instead of exiting. // it: one in the background that tries is stopped instead of exiting.
// The function also removes the temporary file that encrypt or decrypt
// is writing.
terminalState, terminalErr := term.GetState(unix.Stdin) terminalState, terminalErr := term.GetState(unix.Stdin)
memguard.CatchSignal(func(os.Signal) { memguard.CatchSignal(func(os.Signal) {
@@ -26,6 +28,11 @@ func Entry() int {
if terminalErr == nil && err == nil && foreground == unix.Getpgrp() { if terminalErr == nil && err == nil && foreground == unix.Getpgrp() {
_ = term.Restore(unix.Stdin, terminalState) _ = term.Restore(unix.Stdin, terminalState)
} }
name := temporaryOutput.Load()
if name != nil {
_ = os.Remove(*name)
}
}, os.Interrupt, unix.SIGTERM) }, os.Interrupt, unix.SIGTERM)
defer memguard.Purge() defer memguard.Purge()
+20 -14
View File
@@ -3,7 +3,6 @@ package secret
import ( import (
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -11,9 +10,8 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
) )
// tempNamePart is in the name of every temporary file WriteFileAtomicFunc // tempNamePart starts the name of every temporary file WriteFileAtomicFunc
// makes, ".NAME.tmp-123", and every temporary directory TempDirFor makes, // makes and every temporary directory TempDirFor makes, ".tmp-123".
// ".tmp-123".
const tempNamePart = ".tmp-" const tempNamePart = ".tmp-"
// WriteFileAtomic replaces the file at path with data so that a reader, or // WriteFileAtomic replaces the file at path with data so that a reader, or
@@ -23,30 +21,38 @@ const tempNamePart = ".tmp-"
// within one filesystem), is synced to disk, and is renamed over path. The // within one filesystem), is synced to disk, and is renamed over path. The
// temporary file is removed if any step fails. // temporary file is removed if any step fails.
func WriteFileAtomic(fs afero.Fs, path string, data []byte) error { func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
return WriteFileAtomicFunc(fs, path, func(w io.Writer) error { return WriteFileAtomicFunc(fs, path, func(file afero.File) error {
_, err := w.Write(data) _, err := file.Write(data)
if err != nil {
return fmt.Errorf("failed to write %s: %w", path, err)
}
return err return nil
}) })
} }
// WriteFileAtomicFunc is WriteFileAtomic for content that write streams // WriteFileAtomicFunc is WriteFileAtomic for content that write streams
// into the temporary file instead of holding it in memory. An error from // into the temporary file, which it is given, instead of holding it in
// write fails it as any other step does. // memory. An error from write is returned as it is. The temporary file's
// name leaves out path's, which may already be as long as a name can be.
func WriteFileAtomicFunc( func WriteFileAtomicFunc(
fs afero.Fs, path string, write func(w io.Writer) error, fs afero.Fs, path string, write func(file afero.File) error,
) error { ) error {
tmp, err := afero.TempFile(fs, filepath.Dir(path), tmp, err := afero.TempFile(fs, filepath.Dir(path), tempNamePart)
"."+filepath.Base(path)+tempNamePart+"*")
if err != nil { if err != nil {
return fmt.Errorf("failed to create temporary file for %s: %w", path, err) return fmt.Errorf("failed to create temporary file for %s: %w", path, err)
} }
err = write(tmp) err = write(tmp)
if err == nil { if err != nil {
err = tmp.Sync() _ = tmp.Close()
_ = fs.Remove(tmp.Name())
return err
} }
err = tmp.Sync()
closeErr := tmp.Close() closeErr := tmp.Close()
if err == nil { if err == nil {
err = closeErr err = closeErr