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
+84 -6
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/signal"
"path/filepath"
@@ -140,7 +141,10 @@ func runDecrypt(cmd *cobra.Command, args []string) error {
// 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
// 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(
cmd *cobra.Command, args []string,
work func(io.Writer, io.Reader) error,
@@ -157,11 +161,14 @@ func through(
return fmt.Errorf("reading the output file: %w", err)
}
if name == "" {
switch name {
case "", "/dev/stdout":
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
@@ -180,7 +187,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/fd/1
// 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 +267,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