1 Commits
Author SHA1 Message Date
sneak 02942b591d 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, replacing a symlink, a named pipe or a device such as
/dev/null with a regular file. A path that is the same file as the
tool's standard output or standard error, under any name, is now
written to that stream, so the file it is redirected to keeps its
contents. Any other path is looked at without following a final
symlink: nothing there or a regular file is replaced by rename as
before, a symlink gets the same treatment for what it points at and is
refused if it points at nothing, and anything else is written to
directly, without catching signals. The README says so, and that a
replaced file has mode 0600.

Model: opus-5-5
2026-10-04 14:33:34 +00:00
3 changed files with 73 additions and 27 deletions
+15 -14
View File
@@ -250,14 +250,15 @@ identity's own recipient, plus any given with `--to`, so the same mnemonic can
always decrypt what it encrypted. Output goes to `-o` or standard output; always decrypt what it encrypted. Output goes to `-o` or standard output;
`--armor` writes the text form. Nothing is written except the output. `--armor` writes the text form. Nothing is written except the output.
A regular file already at the `-o` path is replaced, and the new file has mode A `-o` path that is the same file as the tool's own standard output or standard
`0600`. A symlink there is followed, and what it points at is treated the same error, under any name such as `/dev/stdout` or `/dev/fd/2`, is written to that
way, so the link keeps pointing where it did; a symlink that points at nothing stream, as leaving out `-o` writes to standard output; the file the stream is
is refused. A named pipe or a device, such as `/dev/null`, is written to redirected to is written as the redirect says and never replaced, so with `>>`
directly. `-o /dev/stdout` writes to the tool's own standard output, as leaving the output follows what the file already held. Otherwise, a regular file already
out `-o` does, and `-o /dev/stderr` to its standard error; a file either stream at the `-o` path is replaced, and the new file has mode `0600`. A symlink there
is redirected to is written as the redirect says and never replaced, so with is followed, and what it points at is treated the same way, so the link keeps
`>>` the output follows what the file already held. pointing where it did; a symlink that points at nothing is refused. A named pipe
or a device, such as `/dev/null`, is written to directly.
### `keyfunc age decrypt [-n N] [-o <file>] [<file>]` ### `keyfunc age decrypt [-n N] [-o <file>] [<file>]`
@@ -306,12 +307,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, `/dev/stdout` or `/dev/stderr`, is named pipe or a device at the `-o` path, or a path that is the same file as
written to directly, and the signal ends the tool there as it ends any other standard output or standard error, is written to directly, and the signal ends
command. While `ssh to` or `ssh install` has `ssh` or `sftp` running, the signal the tool there as it ends any other command. While `ssh to` or `ssh install` has
ends that program instead, the tool removes its agent socket or working files, `ssh` or `sftp` running, the signal ends that program instead, the tool removes
and it exits with status 1, or for `ssh to` with `ssh`'s own status if `ssh` its agent socket or working files, and it exits with status 1, or for `ssh to`
reported one. with `ssh`'s own status if `ssh` reported one.
## Entrypoints ## Entrypoints
+30 -8
View File
@@ -141,10 +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 or names /dev/stdout, and the command's own // output when it names none or names the same file as that output, and
// error output when it names /dev/stderr. Those two are the streams the // the command's own error output when it names the same file as that.
// tool already has, so whatever they are redirected to is written as // Those two are the streams the tool already has, so whatever they are
// the redirect says, never replaced. // 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,
@@ -161,16 +161,38 @@ func through(
return fmt.Errorf("reading the output file: %w", err) return fmt.Errorf("reading the output file: %w", err)
} }
switch name { switch {
case "", "/dev/stdout": case name == "", same(name, cmd.OutOrStdout()):
return work(cmd.OutOrStdout(), src) return work(cmd.OutOrStdout(), src)
case "/dev/stderr": case same(name, cmd.ErrOrStderr()):
return work(cmd.ErrOrStderr(), src) return work(cmd.ErrOrStderr(), src)
default: default:
return output(name, src, work) return output(name, src, work)
} }
} }
// same reports whether the named path, followed to the end, is the
// file the stream writes to, whatever name it is reached by, such as
// /dev/stdout or /dev/fd/1 for standard output.
func same(name string, stream io.Writer) bool {
file, ok := stream.(*os.File)
if !ok {
return false
}
streamInfo, err := file.Stat()
if err != nil {
return false
}
info, err := os.Stat(name)
if err != nil {
return false
}
return os.SameFile(info, streamInfo)
}
// 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
// that was opened and does nothing otherwise. // that was opened and does nothing otherwise.
@@ -210,7 +232,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/fd/1 // os.Stat follows the link as opening it would. /dev/fd/3
// 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 -5
View File
@@ -151,14 +151,32 @@ 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) { func TestANameForStandardOutputAddsToTheFileItIsAppendedTo(t *testing.T) {
t.Setenv(mnemonic.Variable, example()) t.Setenv(mnemonic.Variable, example())
sealed := filepath.Join(t.TempDir(), "notes.age") sealed := filepath.Join(t.TempDir(), "notes.age")
run(t, "age", "encrypt", "-o", sealed, written(t, "notes.txt", "the secret\n")) 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. for _, name := range []string{"/dev/stdout", "/dev/fd/1"} {
appendedThrough(t, name, sealed)
}
}
// appendedThrough decrypts sealed with -o name while the tool's standard
// output is appended to a file that already has contents, as the shell's
// ">> notes.out" does, and checks that the file is the same one, with
// the same mode, and holds its earlier contents and then the output.
func appendedThrough(t *testing.T, name, sealed string) {
t.Helper()
// A mode of its own, so that a replaced file would show.
const ownMode = 0o644
existing := written(t, "notes.out", "what was already there\n") existing := written(t, "notes.out", "what was already there\n")
require.NoError(t, os.Chmod(existing, ownMode))
before, err := os.Stat(existing)
require.NoError(t, err)
//nolint:gosec // the test made this path itself //nolint:gosec // the test made this path itself
appended, err := os.OpenFile(existing, os.O_WRONLY|os.O_APPEND, 0) appended, err := os.OpenFile(existing, os.O_WRONLY|os.O_APPEND, 0)
@@ -168,16 +186,21 @@ func TestDevStdoutAddsToTheFileStandardOutputIsAppendedTo(t *testing.T) {
//nolint:gosec // this test's own binary as the tool //nolint:gosec // this test's own binary as the tool
command := exec.CommandContext( command := exec.CommandContext(
t.Context(), os.Args[0], "age", "decrypt", "-o", "/dev/stdout", sealed, t.Context(), os.Args[0], "age", "decrypt", "-o", name, sealed,
) )
command.Env = append(os.Environ(), runAsTool+"=1") command.Env = append(os.Environ(), runAsTool+"=1")
command.Stdout = appended command.Stdout = appended
require.NoError(t, command.Run()) require.NoError(t, command.Run(), name)
require.Equal(t, require.Equal(t,
"what was already there\nthe secret\n", read(t, existing), "what was already there\nthe secret\n", read(t, existing), name,
) )
after, err := os.Stat(existing)
require.NoError(t, err)
require.True(t, os.SameFile(before, after), name)
require.Equal(t, os.FileMode(ownMode), after.Mode().Perm(), name)
} }
func TestASignalStopsAnEncryptionAndLeavesNoFile(t *testing.T) { func TestASignalStopsAnEncryptionAndLeavesNoFile(t *testing.T) {