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 wrote a new file beside the
named path and renamed it over that 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. 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:07:15 +00:00
parent dad29597bd
commit 983f554ac0
4 changed files with 148 additions and 14 deletions
+19 -11
View File
@@ -250,11 +250,17 @@ 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;
`--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
`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
is refused. A named pipe or a device, such as `/dev/null` or `/dev/stdout`, is
written to directly.
### `keyfunc age decrypt [-n N] [-o <file>] [<file>]`
Decrypts the file (or standard input) with the derived identity. Output goes to
`-o` or standard output. If the identity is not one of the recipients, the tool
says so and exits with status 1.
`-o`, which is treated as for `encrypt`, or standard output. If the identity is
not one of the recipients, the tool says so and exits with status 1.
## Derived mnemonics: `keyfunc mnemonic`
@@ -291,15 +297,17 @@ passes through `ssh`'s own exit status.
SIGINT, SIGTERM and SIGHUP end any command at once, at the mnemonic prompt too,
with the status a shell gives a program killed by that signal (130 for SIGINT).
While `age encrypt -o` or `age decrypt -o` is writing the file, the signal makes
it remove the unfinished file, leave a file 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 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. While `ssh to` or `ssh install` has `ssh` or
`sftp` running, the signal ends that program instead, the tool removes its agent
socket or working files, and it exits with status 1, or for `ssh to` with
`ssh`'s own status if `ssh` reported one.
While `age encrypt -o` or `age decrypt -o` is writing a new file or replacing a
regular one, the signal makes it remove the unfinished file, leave a file
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
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
named pipe or a device at the `-o` path is written to directly, and the signal
ends the tool there as it ends any other command. While `ssh to` or
`ssh install` has `ssh` or `sftp` running, the signal ends that program instead,
the tool removes its agent socket or working files, and it exits with status 1,
or for `ssh to` with `ssh`'s own status if `ssh` reported one.
## Entrypoints
+74 -2
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/signal"
"path/filepath"
@@ -180,7 +181,78 @@ func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) {
return file, func() { _ = file.Close() }, nil
}
// output has the work write a new file beside the named one, and puts
// output has the work write to the named path, going by what is there
// without following a final symlink:
//
// - nothing, or a regular file: replace writes a new file beside it
// and renames that over it;
// - a symlink: the same for what it points at, so that the link keeps
// pointing where it did; one that points at nothing is refused;
// - anything else, such as a named pipe or a device like /dev/null:
// direct writes to it, since a rename would put a regular file in
// its place.
func output(
name string, src io.Reader, work func(io.Writer, io.Reader) error,
) error {
info, err := os.Lstat(name)
switch {
case errors.Is(err, fs.ErrNotExist):
return replace(name, src, work)
case err != nil:
return fmt.Errorf("looking at %s: %w", name, err)
case info.Mode().IsRegular():
return replace(name, src, work)
case info.Mode().Type() == fs.ModeSymlink:
// os.Stat follows the link as opening it would. /dev/stdout
// needs that: it reaches a pipe or a terminal through a link
// that names no path.
info, err = os.Stat(name)
if err != nil {
return fmt.Errorf("following %s: %w", name, err)
}
if !info.Mode().IsRegular() {
return direct(name, src, work)
}
target, err := filepath.EvalSymlinks(name)
if err != nil {
return fmt.Errorf("following %s: %w", name, err)
}
return replace(target, src, work)
default:
return direct(name, src, work)
}
}
// direct has the work write straight to the named path, which is there
// and is not a regular file. No signal is caught, so one ends the tool
// as it ends any other command.
func direct(
name string, src io.Reader, work func(io.Writer, io.Reader) error,
) error {
file, err := os.OpenFile(name, os.O_WRONLY, 0) //nolint:gosec // the -o path
if err != nil {
return fmt.Errorf("opening %s: %w", name, err)
}
failed := work(file, src)
closeErr := file.Close()
if failed != nil {
return failed
}
if closeErr != nil {
return fmt.Errorf("finishing %s: %w", name, closeErr)
}
return nil
}
// replace has the work write a new file beside the named one, and puts
// the new file in the named file's place only when the work succeeded,
// so a file that is already there survives a run that failed.
//
@@ -189,7 +261,7 @@ func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) {
// new file is removed and ErrInterrupted returned, at once if the work
// is still running, without waiting for it, since it may be blocked
// reading its input.
func output(
func replace(
name string, src io.Reader, work func(io.Writer, io.Reader) error,
) error {
// received is registered before the context, so it gets every
+53
View File
@@ -3,6 +3,7 @@ package cli_test
import (
"errors"
"io"
"io/fs"
"os"
"os/exec"
"os/signal"
@@ -98,6 +99,58 @@ 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 TestASignalStopsAnEncryptionAndLeavesNoFile(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
+2 -1
View File
@@ -85,7 +85,8 @@ func init() {
// signals to clean up first: "ssh to" and "ssh install" while they
// have ssh or sftp running, so the child ends and their own cleanup
// still runs, and "age encrypt -o" and "age decrypt -o" while they
// write, so the unfinished file is removed.
// write a new file to rename over the named one, so the unfinished file
// is removed.
func Main() int {
err := Root().Execute()
if err == nil {