age -o follows a symlink and writes a pipe or device directly (closes #59)
check / check (push) Failing after 2s
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
This commit is contained in:
@@ -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 <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 +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
|
||||
|
||||
|
||||
+105
-5
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user