From 407490f824d7cc7f24c734b64e007221bbb997c8 Mon Sep 17 00:00:00 2001 From: sneak Date: Sun, 4 Oct 2026 13:07:15 +0000 Subject: [PATCH] age -o follows a symlink and writes a pipe or device directly (closes #59) 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 --- README.md | 34 ++++++++++----- internal/cli/age/age.go | 90 +++++++++++++++++++++++++++++++++++++--- internal/cli/age_test.go | 82 ++++++++++++++++++++++++++++++++++++ internal/cli/cli.go | 3 +- 4 files changed, 191 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 2fecc6c..f8d7dbe 100644 --- a/README.md +++ b/README.md @@ -250,11 +250,20 @@ 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`, is written to +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 ] []` 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 +300,18 @@ 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, `/dev/stdout` or `/dev/stderr`, 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 diff --git a/internal/cli/age/age.go b/internal/cli/age/age.go index b9d4609..170cfac 100644 --- a/internal/cli/age/age.go +++ b/internal/cli/age/age.go @@ -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 diff --git a/internal/cli/age_test.go b/internal/cli/age_test.go index 2209db9..6d2a974 100644 --- a/internal/cli/age_test.go +++ b/internal/cli/age_test.go @@ -3,6 +3,7 @@ package cli_test import ( "errors" "io" + "io/fs" "os" "os/exec" "os/signal" @@ -98,6 +99,87 @@ 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()) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7824e68..dabad05 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -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 {