From 5b36e42e4d2fbd0ef9f4994f055b13bc410a3450 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Sun, 4 Oct 2026 16:59:02 +0200 Subject: [PATCH] age -o keeps a symlink, pipe, device or redirected stream at the path (closes #59) age encrypt -o and age decrypt -o now look at the -o path before writing. A path that is the same file as the tool's standard output or standard error, under any name, is written to that stream, so a redirected file keeps its contents, inode and mode. A new path or a regular file is written beside it and renamed over it, as before, with the signal handling of #48. A symlink gets the same rule for what it points at, so the link survives; a dangling one is refused. A named pipe or a device is written directly. The README says a replaced file gets mode 0600. Rule suppressed: gosec G304 on the direct open of the -o path. Model: opus-5-5 --- README.md | 35 +++++++++---- internal/cli/age/age.go | 110 +++++++++++++++++++++++++++++++++++++-- internal/cli/age_test.go | 105 +++++++++++++++++++++++++++++++++++++ internal/cli/cli.go | 3 +- 4 files changed, 236 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 2fecc6c..511f00c 100644 --- a/README.md +++ b/README.md @@ -250,11 +250,21 @@ 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 `-o` path that is the same file as the tool's own standard output or standard +error, under any name such as `/dev/stdout` or `/dev/fd/2`, is written to that +stream, as leaving out `-o` writes to standard output; the file the stream is +redirected to is written as the redirect says and never replaced, so with `>>` +the output follows what the file already held. Otherwise, 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. + ### `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 +301,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, or a path that is the same file as +standard output or standard error, 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..a1f9f32 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 the same file as that output, and +// the command's own error output when it names the same file as that. +// 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,36 @@ func through( return fmt.Errorf("reading the output file: %w", err) } - if name == "" { + switch { + case name == "", same(name, cmd.OutOrStdout()): return work(cmd.OutOrStdout(), src) + case same(name, cmd.ErrOrStderr()): + return work(cmd.ErrOrStderr(), src) + default: + 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 } - return output(name, src, work) + 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 @@ -180,7 +209,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/3 + // 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 +289,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..2ee31e7 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,110 @@ 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 TestANameForStandardOutputAddsToTheFileItIsAppendedTo(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")) + + 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") + require.NoError(t, os.Chmod(existing, ownMode)) + + before, err := os.Stat(existing) + require.NoError(t, err) + + //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", name, sealed, + ) + + command.Env = append(os.Environ(), runAsTool+"=1") + command.Stdout = appended + + require.NoError(t, command.Run(), name) + require.Equal(t, + "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) { 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 {