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 }