age -o keeps a symlink, pipe, device or redirected stream at the path (closes #59)
check / check (push) Failing after 2s
check / check (push) Failing after 2s
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
This commit was merged in pull request #60.
This commit is contained in:
+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