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
466 lines
14 KiB
Go
466 lines
14 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/keyfunc/internal/agekey"
|
|
"sneak.berlin/go/keyfunc/internal/cli"
|
|
"sneak.berlin/go/keyfunc/internal/cli/age"
|
|
"sneak.berlin/go/keyfunc/internal/mnemonic"
|
|
)
|
|
|
|
func TestTheAgeCommandsPrintTheKey(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
recipient := strings.TrimSpace(run(t, "age", "pub"))
|
|
require.True(t, strings.HasPrefix(recipient, "age1"))
|
|
|
|
identity := strings.TrimSpace(run(t, "age", "priv"))
|
|
require.True(t, strings.HasPrefix(identity, "AGE-SECRET-KEY-1"))
|
|
}
|
|
|
|
func TestAFileEncryptedByTheToolIsReadBackByIt(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
plain := written(t, "notes.txt", "the secret\n")
|
|
sealed := filepath.Join(t.TempDir(), "notes.age")
|
|
|
|
run(t, "age", "encrypt", "-o", sealed, plain)
|
|
require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed))
|
|
}
|
|
|
|
func TestTheArmoredFormIsTextThatDecrypts(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
plain := written(t, "notes.txt", "the secret\n")
|
|
|
|
armored := run(t, "age", "encrypt", "--armor", plain)
|
|
require.True(t, strings.HasPrefix(
|
|
armored, "-----BEGIN AGE ENCRYPTED FILE-----",
|
|
))
|
|
|
|
sealed := written(t, "notes.age", armored)
|
|
require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed))
|
|
}
|
|
|
|
func TestAnotherRecipientIsAddedAndTheDerivedOneStays(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
theirs := strings.TrimSpace(run(t, "age", "pub", "-n", "7"))
|
|
plain := written(t, "notes.txt", "the secret\n")
|
|
sealed := filepath.Join(t.TempDir(), "notes.age")
|
|
|
|
run(t, "age", "encrypt", "--to", theirs, "-o", sealed, plain)
|
|
|
|
require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed))
|
|
require.Equal(t,
|
|
"the secret\n", run(t, "age", "decrypt", "-n", "7", sealed),
|
|
)
|
|
}
|
|
|
|
func TestAFileForAnotherKeyIsRefused(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
plain := written(t, "notes.txt", "the secret\n")
|
|
sealed := filepath.Join(t.TempDir(), "notes.age")
|
|
|
|
run(t, "age", "encrypt", "-n", "7", "-o", sealed, plain)
|
|
|
|
_, err := execute(t, "age", "decrypt", sealed)
|
|
require.ErrorIs(t, err, agekey.ErrNotRecipient)
|
|
}
|
|
|
|
func TestARefusedDecryptionLeavesTheOutputFileAlone(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
plain := written(t, "notes.txt", "the secret\n")
|
|
sealed := filepath.Join(t.TempDir(), "notes.age")
|
|
existing := written(t, "notes.out", "what was already there\n")
|
|
|
|
run(t, "age", "encrypt", "-n", "7", "-o", sealed, plain)
|
|
|
|
_, err := execute(t, "age", "decrypt", "-o", existing, sealed)
|
|
require.ErrorIs(t, err, agekey.ErrNotRecipient)
|
|
|
|
//nolint:gosec // the test made this path itself
|
|
kept, err := os.ReadFile(existing)
|
|
require.NoError(t, err)
|
|
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())
|
|
|
|
for _, ending := range []os.Signal{
|
|
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
|
|
} {
|
|
interrupted(t, ending, "encrypt", "the start of the secret\n")
|
|
}
|
|
}
|
|
|
|
func TestASignalStopsADecryptionAndLeavesNoFile(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
// All of an encryption but its last byte, so the tool reads the
|
|
// header and then waits for the rest.
|
|
sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n"))
|
|
cut := sealed[:len(sealed)-1]
|
|
|
|
for _, ending := range []os.Signal{
|
|
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
|
|
} {
|
|
interrupted(t, ending, "decrypt", cut)
|
|
}
|
|
}
|
|
|
|
func TestASignalReceivedAsTheInputEndsLeavesTheFileAsItWas(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n"))
|
|
|
|
for _, ending := range []syscall.Signal{
|
|
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
|
|
} {
|
|
receivedAtTheEnd(t, ending, "encrypt", "the secret\n")
|
|
receivedAtTheEnd(t, ending, "decrypt", sealed)
|
|
}
|
|
}
|
|
|
|
func TestASignalAsTheInputEndsLeavesNoUnfinishedFile(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n"))
|
|
|
|
// Ctrl-C on "producer | keyfunc age encrypt -o file" ends the
|
|
// producer too, so the input ends just as the signal comes, with
|
|
// enough of it in hand for a whole encryption or decryption. Which
|
|
// of the two the tool has first varies, so it is tried often, and
|
|
// a whole file in place is accepted as well as none.
|
|
for range 25 {
|
|
named := signalledAsTheInputEnds(t, "encrypt", "the start of the secret\n")
|
|
if named != "" {
|
|
require.Equal(t,
|
|
"the start of the secret\n", run(t, "age", "decrypt", named),
|
|
)
|
|
}
|
|
|
|
named = signalledAsTheInputEnds(t, "decrypt", sealed)
|
|
if named != "" {
|
|
require.Equal(t, "the secret\n", read(t, named))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnEncryptionStartedUnderNohupSurvivesAHangup(t *testing.T) {
|
|
t.Setenv(mnemonic.Variable, example())
|
|
|
|
directory := t.TempDir()
|
|
named := filepath.Join(directory, "notes")
|
|
|
|
// nohup starts the tool with SIGHUP ignored. A tool that caught it
|
|
// anyway would turn it back on and be ended by it.
|
|
command, producer := writing(
|
|
t, directory, "the secret\n",
|
|
"nohup", os.Args[0], "age", "encrypt", "-o", named,
|
|
)
|
|
|
|
require.NoError(t, command.Process.Signal(syscall.SIGHUP))
|
|
require.NoError(t, producer.Close())
|
|
waitForTool(t, "SIGHUP under nohup", command)
|
|
|
|
require.Equal(t, 0, command.ProcessState.ExitCode())
|
|
|
|
left, err := os.ReadDir(directory)
|
|
require.NoError(t, err)
|
|
require.Len(t, left, 1)
|
|
require.Equal(t, "the secret\n", run(t, "age", "decrypt", named))
|
|
}
|
|
|
|
// interrupted runs "age encrypt -o" or "age decrypt -o", as the
|
|
// operation says, writing into a directory of its own, and once it has
|
|
// begun writing sends it the signal and leaves the input open. The tool
|
|
// has to end with status 1 and leave the directory empty. A tool that
|
|
// went on reading would not end until the input did; one that did not
|
|
// remove the file it was writing would leave it there, with what it had
|
|
// written so far.
|
|
func interrupted(t *testing.T, ending os.Signal, operation, input string) {
|
|
t.Helper()
|
|
|
|
name := operation + " " + ending.String()
|
|
directory := t.TempDir()
|
|
|
|
command, _ := writing(
|
|
t, directory, input,
|
|
os.Args[0], "age", operation, "-o", filepath.Join(directory, "notes"),
|
|
)
|
|
|
|
require.NoError(t, command.Process.Signal(ending))
|
|
waitForTool(t, name, command)
|
|
|
|
require.Equal(t, 1, command.ProcessState.ExitCode(), name)
|
|
|
|
left, err := os.ReadDir(directory)
|
|
require.NoError(t, err)
|
|
require.Empty(t, left, name)
|
|
}
|
|
|
|
// signalledAsTheInputEnds runs "age encrypt -o" or "age decrypt -o", as
|
|
// the operation says, writing into a directory of its own, and once it
|
|
// has begun writing sends it SIGINT and at once ends its input. Either
|
|
// the tool ends with status 1 and leaves the directory empty, and ""
|
|
// is returned, or it ends otherwise and leaves only the named file,
|
|
// whose path is returned for the caller to check that it is whole.
|
|
func signalledAsTheInputEnds(t *testing.T, operation, input string) string {
|
|
t.Helper()
|
|
|
|
directory := t.TempDir()
|
|
named := filepath.Join(directory, "notes")
|
|
|
|
command, producer := writing(
|
|
t, directory, input, os.Args[0], "age", operation, "-o", named,
|
|
)
|
|
|
|
require.NoError(t, command.Process.Signal(syscall.SIGINT))
|
|
require.NoError(t, producer.Close())
|
|
waitForTool(t, operation, command)
|
|
|
|
left, err := os.ReadDir(directory)
|
|
require.NoError(t, err)
|
|
|
|
if command.ProcessState.ExitCode() == failedStatus {
|
|
require.Empty(t, left, operation)
|
|
|
|
return ""
|
|
}
|
|
|
|
require.Len(t, left, 1, operation)
|
|
|
|
return named
|
|
}
|
|
|
|
// receivedAtTheEnd runs "age encrypt -o" or "age decrypt -o", as the
|
|
// operation says, in this process, over a file that is already there,
|
|
// with an input that at its end sends this process the signal and waits
|
|
// until it has been received. The tool has to return ErrInterrupted and
|
|
// leave that file as it was, with nothing beside it. A tool that went
|
|
// by the end of the input alone would put its new file in place.
|
|
func receivedAtTheEnd(
|
|
t *testing.T, ending syscall.Signal, operation, input string,
|
|
) {
|
|
t.Helper()
|
|
|
|
name := operation + " " + ending.String()
|
|
existing := written(t, "notes", "what was already there\n")
|
|
|
|
// The test catches the signal as well, so that it does not end the
|
|
// test binary and so that the input can wait for it.
|
|
received := make(chan os.Signal, 1)
|
|
signal.Notify(received, ending)
|
|
|
|
defer signal.Stop(received)
|
|
|
|
root := cli.Root()
|
|
root.SetIn(&endingInASignal{
|
|
rest: strings.NewReader(input), ending: ending, received: received,
|
|
})
|
|
root.SetOut(io.Discard)
|
|
root.SetErr(io.Discard)
|
|
root.SetArgs([]string{"age", operation, "-o", existing})
|
|
|
|
err := root.ExecuteContext(t.Context())
|
|
require.ErrorIs(t, err, age.ErrInterrupted, name)
|
|
|
|
require.Equal(t, "what was already there\n", read(t, existing), name)
|
|
|
|
left, err := os.ReadDir(filepath.Dir(existing))
|
|
require.NoError(t, err)
|
|
require.Len(t, left, 1, name)
|
|
}
|
|
|
|
// endingInASignal is an input that, when it runs out, sends this
|
|
// process its signal and waits for it on received before it reports its
|
|
// end. It sends the signal only once: once nothing catches it, another
|
|
// would end the test binary.
|
|
type endingInASignal struct {
|
|
rest io.Reader
|
|
ending syscall.Signal
|
|
received chan os.Signal
|
|
sent bool
|
|
}
|
|
|
|
func (input *endingInASignal) Read(buffer []byte) (int, error) {
|
|
n, err := input.rest.Read(buffer)
|
|
if !errors.Is(err, io.EOF) || input.sent {
|
|
return n, err
|
|
}
|
|
|
|
input.sent = true
|
|
|
|
err = syscall.Kill(os.Getpid(), input.ending)
|
|
if err != nil {
|
|
return n, err
|
|
}
|
|
|
|
<-input.received
|
|
|
|
return n, io.EOF
|
|
}
|
|
|
|
// writing starts argv, the tool told to write into directory, as a
|
|
// subprocess reading the input from a pipe, and returns once the tool
|
|
// has begun writing the file beside the one it was named. The pipe is
|
|
// left open for the caller to end.
|
|
func writing(
|
|
t *testing.T, directory, input string, argv ...string,
|
|
) (*exec.Cmd, io.WriteCloser) {
|
|
t.Helper()
|
|
|
|
//nolint:gosec // this test's own binary as the tool, or nohup running it
|
|
command := exec.CommandContext(t.Context(), argv[0], argv[1:]...)
|
|
|
|
command.Env = append(os.Environ(), runAsTool+"=1")
|
|
|
|
producer, err := command.StdinPipe()
|
|
require.NoError(t, err)
|
|
require.NoError(t, command.Start())
|
|
|
|
_, err = io.WriteString(producer, input)
|
|
require.NoError(t, err)
|
|
|
|
// The file beside the named one is made once the mnemonic has been
|
|
// read, before any input is.
|
|
require.Eventually(t, func() bool {
|
|
entries, err := os.ReadDir(directory)
|
|
|
|
return err == nil && len(entries) > 0
|
|
}, 5*time.Second, 5*time.Millisecond)
|
|
|
|
return command, producer
|
|
}
|
|
|
|
// written puts the contents in a file of that name in a directory of
|
|
// this test's own and returns the path to it.
|
|
func written(t *testing.T, name, contents string) string {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(t.TempDir(), name)
|
|
require.NoError(t, os.WriteFile(path, []byte(contents), 0o600))
|
|
|
|
return path
|
|
}
|