//nolint:testpackage // sets the unexported fields of Instance package cli import ( "io" "os" "path/filepath" "strings" "syscall" "testing" "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/stretchr/testify/require" "sneak.berlin/go/secret/internal/vault" ) // plainText is the content of plain.txt in the directory newCryptoTest makes. const plainText = "document" // newCryptoTest returns an Instance whose current vault, in a new state // directory on the real filesystem, holds the age key secret "key", and a // new directory holding only plain.txt and plain.txt.age, its encryption // with that key. func newCryptoTest(t *testing.T) (*Instance, string) { t.Helper() fs := afero.NewOsFs() stateDir := t.TempDir() _, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil) require.NoError(t, err) cli := NewCLIInstanceWithStateDir(fs, stateDir) cli.Mnemonic = testMnemonicBuffer(t) cli.cmd = &cobra.Command{} dir := t.TempDir() plain := filepath.Join(dir, "plain.txt") require.NoError(t, afero.WriteFile(fs, plain, []byte(plainText), 0o600)) require.NoError(t, cli.Encrypt("key", plain, plain+".age")) return cli, dir } // TestFailedCryptoLeavesOutputUnchanged is a regression test for // https://git.eeqj.de/sneak/secret/issues/143, where secret encrypt and // secret decrypt emptied the file named by --output before they failed. // A failed command must leave an existing file as it was and create none, // and give the same error as without --output. func TestFailedCryptoLeavesOutputUnchanged(t *testing.T) { t.Parallel() tests := []struct { name string run func(cli *Instance, dir, output string) error }{ // Reading a directory fails once encryption has begun {"encrypt", func(cli *Instance, dir, output string) error { return cli.Encrypt("key", dir, output) }}, // plain.txt is not an age file {"decrypt", func(cli *Instance, dir, output string) error { return cli.Decrypt("key", filepath.Join(dir, "plain.txt"), output) }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() cli, dir := newCryptoTest(t) existing := filepath.Join(dir, "existing.txt") require.NoError(t, afero.WriteFile(cli.fs, existing, []byte("precious"), 0o600)) cli.cmd.SetOut(io.Discard) toStdout := tt.run(cli, dir, "") require.Error(t, toStdout) require.EqualError(t, tt.run(cli, dir, existing), toStdout.Error()) require.EqualError(t, tt.run(cli, dir, filepath.Join(dir, "new.txt")), toStdout.Error()) content, err := afero.ReadFile(cli.fs, existing) require.NoError(t, err) require.Equal(t, "precious", string(content)) // No new.txt, and no temporary file left behind entries, err := afero.ReadDir(cli.fs, dir) require.NoError(t, err) names := make([]string, 0, len(entries)) for _, entry := range entries { names = append(names, entry.Name()) } require.Equal(t, []string{"existing.txt", "plain.txt", "plain.txt.age"}, names) }) } } // TestCryptoInPlace checks that secret encrypt and secret decrypt given // one file as both --input and --output replace it with the right result. // Before https://git.eeqj.de/sneak/secret/issues/143 they emptied the file // before reading it. func TestCryptoInPlace(t *testing.T) { t.Parallel() cli, dir := newCryptoTest(t) file := filepath.Join(dir, "plain.txt") require.NoError(t, cli.Encrypt("key", file, file)) require.NoError(t, cli.Decrypt("key", file, file)) content, err := afero.ReadFile(cli.fs, file) require.NoError(t, err) require.Equal(t, plainText, string(content)) } // TestDecryptToPipe checks that secret decrypt writes into a named pipe // given as --output, as it must into a device such as /dev/null, instead // of replacing it with a file. func TestDecryptToPipe(t *testing.T) { t.Parallel() cli, dir := newCryptoTest(t) pipe := filepath.Join(dir, "pipe") require.NoError(t, syscall.Mkfifo(pipe, 0o600)) received := make(chan []byte, 1) go func() { content, _ := afero.ReadFile(cli.fs, pipe) received <- content }() require.NoError(t, cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"), pipe)) info, err := os.Lstat(pipe) require.NoError(t, err) require.Equal(t, os.ModeNamedPipe, info.Mode().Type()) require.Equal(t, plainText, string(<-received)) } // TestDecryptThroughLink checks that secret decrypt given a symbolic link // as --output replaces the file the link points to, in another directory, // and keeps the link, as writing through the link would. func TestDecryptThroughLink(t *testing.T) { t.Parallel() cli, dir := newCryptoTest(t) targetDir := t.TempDir() target := filepath.Join(targetDir, "target.txt") require.NoError(t, afero.WriteFile(cli.fs, target, []byte("old"), 0o600)) link := filepath.Join(dir, "link") require.NoError(t, os.Symlink(target, link)) require.NoError(t, cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"), link)) info, err := os.Lstat(link) require.NoError(t, err) require.Equal(t, os.ModeSymlink, info.Mode().Type()) content, err := afero.ReadFile(cli.fs, target) require.NoError(t, err) require.Equal(t, plainText, string(content)) // No temporary file left beside the target entries, err := afero.ReadDir(cli.fs, targetDir) require.NoError(t, err) require.Len(t, entries, 1) } // TestDecryptThroughLinkToMissingFile checks that secret decrypt given a // symbolic link to a file that does not exist yet as --output creates that // file and keeps the link, as writing through the link would. Here --output // is a relative link to a second link, which points into another directory. func TestDecryptThroughLinkToMissingFile(t *testing.T) { t.Parallel() cli, dir := newCryptoTest(t) targetDir := t.TempDir() target := filepath.Join(targetDir, "target.txt") link := filepath.Join(dir, "link") require.NoError(t, os.Symlink("second-link", link)) require.NoError(t, os.Symlink(target, filepath.Join(dir, "second-link"))) require.NoError(t, cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"), link)) for _, name := range []string{"link", "second-link"} { info, err := os.Lstat(filepath.Join(dir, name)) require.NoError(t, err) require.Equal(t, os.ModeSymlink, info.Mode().Type(), name) } content, err := afero.ReadFile(cli.fs, target) require.NoError(t, err) require.Equal(t, plainText, string(content)) // No temporary file left beside the target entries, err := afero.ReadDir(cli.fs, targetDir) require.NoError(t, err) require.Len(t, entries, 1) } // TestDecryptThroughLoopingLink checks that secret decrypt given a symbolic // link that points to itself as --output fails, as writing through it // would, and leaves the link as it was. func TestDecryptThroughLoopingLink(t *testing.T) { t.Parallel() cli, dir := newCryptoTest(t) link := filepath.Join(dir, "link") require.NoError(t, os.Symlink("link", link)) require.Error(t, cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"), link)) target, err := os.Readlink(link) require.NoError(t, err) require.Equal(t, "link", target) } // TestDecryptToLongestName checks that secret decrypt writes an --output // whose name is as long as a file name can be, 255 bytes on Linux and // macOS: the temporary file's name must not be longer. func TestDecryptToLongestName(t *testing.T) { t.Parallel() const longestName = 255 cli, dir := newCryptoTest(t) output := filepath.Join(dir, strings.Repeat("a", longestName)) require.NoError(t, cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"), output)) content, err := afero.ReadFile(cli.fs, output) require.NoError(t, err) require.Equal(t, plainText, string(content)) }