age -o follows a symlink and writes a pipe or device directly (closes #59)
check / check (push) Failing after 2s

age encrypt -o and age decrypt -o always renamed a new file over the
named path, which replaced a symlink, a named pipe or a device such as
/dev/null with a regular file and made -o /dev/stdout fail. The path is
now looked at without following a final symlink: a missing path or a
regular file is replaced by rename as before, a symlink gets the same
treatment for what it points at, and anything else is written to
directly, without catching signals. A symlink that points at nothing is
refused. -o /dev/stdout and -o /dev/stderr write to the tool's own
streams, so a file they are redirected to is never replaced. The README
says so, and that a replaced file has mode 0600.

Model: opus-5-5
This commit is contained in:
2026-10-04 13:46:26 +00:00
parent dad29597bd
commit 55fe7193d6
4 changed files with 190 additions and 18 deletions
+81
View File
@@ -3,6 +3,7 @@ package cli_test
import (
"errors"
"io"
"io/fs"
"os"
"os/exec"
"os/signal"
@@ -98,6 +99,86 @@ func TestARefusedDecryptionLeavesTheOutputFileAlone(t *testing.T) {
require.Equal(t, "what was already there\n", string(kept))
}
func TestASymlinkAtTheOutputPathStaysAndItsTargetGetsTheOutput(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
plain := written(t, "notes.txt", "the secret\n")
target := written(t, "notes.age", "what was already there\n")
link := filepath.Join(t.TempDir(), "notes.age")
require.NoError(t, os.Symlink(target, link))
run(t, "age", "encrypt", "-o", link, plain)
pointsAt, err := os.Readlink(link)
require.NoError(t, err)
require.Equal(t, target, pointsAt)
require.Equal(t, "the secret\n", run(t, "age", "decrypt", target))
}
func TestANamedPipeAtTheOutputPathIsWrittenToAndStaysAPipe(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
plain := written(t, "notes.txt", "the secret\n")
pipe := filepath.Join(t.TempDir(), "notes.age")
require.NoError(t, syscall.Mkfifo(pipe, fileMode))
// Opening the pipe to read waits until the tool opens it to write.
var sealed []byte
finished := make(chan error, 1)
go func() {
var err error
sealed, err = os.ReadFile(pipe) //nolint:gosec // the test's own path
finished <- err
}()
run(t, "age", "encrypt", "-o", pipe, plain)
select {
case err := <-finished:
require.NoError(t, err)
case <-time.After(5 * time.Second):
t.Fatal("nothing was written to the pipe")
}
info, err := os.Lstat(pipe)
require.NoError(t, err)
require.Equal(t, fs.ModeNamedPipe, info.Mode().Type())
sealedFile := written(t, "notes.age", string(sealed))
require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealedFile))
}
func TestDevStdoutAddsToTheFileStandardOutputIsAppendedTo(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
sealed := filepath.Join(t.TempDir(), "notes.age")
run(t, "age", "encrypt", "-o", sealed, written(t, "notes.txt", "the secret\n"))
// What the shell's ">> notes.out" gives the tool as standard output.
existing := written(t, "notes.out", "what was already there\n")
//nolint:gosec // the test made this path itself
appended, err := os.OpenFile(existing, os.O_WRONLY|os.O_APPEND, 0)
require.NoError(t, err)
defer func() { _ = appended.Close() }()
//nolint:gosec // this test's own binary as the tool
command := exec.CommandContext(
t.Context(), os.Args[0], "age", "decrypt", "-o", "/dev/stdout", sealed,
)
command.Env = append(os.Environ(), runAsTool+"=1")
command.Stdout = appended
require.NoError(t, command.Run())
require.Equal(t,
"what was already there\nthe secret\n", read(t, existing),
)
}
func TestASignalStopsAnEncryptionAndLeavesNoFile(t *testing.T) {
t.Setenv(mnemonic.Variable, example())