1 Commits
Author SHA1 Message Date
sneak 55fe7193d6 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
2026-10-04 13:46:26 +00:00
3 changed files with 50 additions and 12 deletions
+11 -7
View File
@@ -253,8 +253,11 @@ always decrypt what it encrypted. Output goes to `-o` or standard output;
A regular file already at the `-o` path is replaced, and the new file has mode A regular file already at the `-o` path is replaced, and the new file has mode
`0600`. A symlink there is followed, and what it points at is treated the same `0600`. A symlink there is followed, and what it points at is treated the same
way, so the link keeps pointing where it did; a symlink that points at nothing way, so the link keeps pointing where it did; a symlink that points at nothing
is refused. A named pipe or a device, such as `/dev/null` or `/dev/stdout`, is is refused. A named pipe or a device, such as `/dev/null`, is written to
written to directly. directly. `-o /dev/stdout` writes to the tool's own standard output, as leaving
out `-o` does, and `-o /dev/stderr` to its standard error; a file either stream
is redirected to is written as the redirect says and never replaced, so with
`>>` the output follows what the file already held.
### `keyfunc age decrypt [-n N] [-o <file>] [<file>]` ### `keyfunc age decrypt [-n N] [-o <file>] [<file>]`
@@ -303,11 +306,12 @@ 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 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 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. A on Linux `keyfunc` sees the signal first, though no system promises that. A
named pipe or a device at the `-o` path is written to directly, and the signal named pipe or a device at the `-o` path, `/dev/stdout` or `/dev/stderr`, is
ends the tool there as it ends any other command. While `ssh to` or written to directly, and the signal ends the tool there as it ends any other
`ssh install` has `ssh` or `sftp` running, the signal ends that program instead, command. While `ssh to` or `ssh install` has `ssh` or `sftp` running, the signal
the tool removes its agent socket or working files, and it exits with status 1, ends that program instead, the tool removes its agent socket or working files,
or for `ssh to` with `ssh`'s own status if `ssh` reported one. and it exits with status 1, or for `ssh to` with `ssh`'s own status if `ssh`
reported one.
## Entrypoints ## Entrypoints
+11 -5
View File
@@ -141,7 +141,10 @@ func runDecrypt(cmd *cobra.Command, args []string) error {
// through opens the input the arguments ask for and hands it to the // 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 // work, with the file --output names to write to, or the command's own
// output when it names none. // output when it names none or names /dev/stdout, and the command's own
// error output when it names /dev/stderr. Those two are the streams the
// tool already has, so whatever they are redirected to is written as
// the redirect says, never replaced.
func through( func through(
cmd *cobra.Command, args []string, cmd *cobra.Command, args []string,
work func(io.Writer, io.Reader) error, work func(io.Writer, io.Reader) error,
@@ -158,12 +161,15 @@ func through(
return fmt.Errorf("reading the output file: %w", err) return fmt.Errorf("reading the output file: %w", err)
} }
if name == "" { switch name {
case "", "/dev/stdout":
return work(cmd.OutOrStdout(), src) return work(cmd.OutOrStdout(), src)
} case "/dev/stderr":
return work(cmd.ErrOrStderr(), src)
default:
return output(name, src, work) return output(name, src, work)
} }
}
// input returns what to read from: the named file, or the command's // input returns what to read from: the named file, or the command's
// own input when no file is named. The second result closes a file // own input when no file is named. The second result closes a file
@@ -204,7 +210,7 @@ func output(
case info.Mode().IsRegular(): case info.Mode().IsRegular():
return replace(name, src, work) return replace(name, src, work)
case info.Mode().Type() == fs.ModeSymlink: case info.Mode().Type() == fs.ModeSymlink:
// os.Stat follows the link as opening it would. /dev/stdout // os.Stat follows the link as opening it would. /dev/fd/1
// needs that: it reaches a pipe or a terminal through a link // needs that: it reaches a pipe or a terminal through a link
// that names no path. // that names no path.
info, err = os.Stat(name) info, err = os.Stat(name)
+28
View File
@@ -151,6 +151,34 @@ func TestANamedPipeAtTheOutputPathIsWrittenToAndStaysAPipe(t *testing.T) {
require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealedFile)) 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) { func TestASignalStopsAnEncryptionAndLeavesNoFile(t *testing.T) {
t.Setenv(mnemonic.Variable, example()) t.Setenv(mnemonic.Variable, example())