//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) }) } } // writeFailFs fails every open of one path for writing, as a file of mode // 0444 does for anyone but root, who runs these tests in Docker. type writeFailFs struct { afero.Fs path string } //nolint:ireturn // afero.File is the interface required by afero.Fs func (f *writeFailFs) OpenFile( name string, flag int, perm os.FileMode, ) (afero.File, error) { if name == f.path && flag&(os.O_WRONLY|os.O_RDWR) != 0 { return nil, &os.PathError{Op: "open", Path: name, Err: syscall.EACCES} } return f.Fs.OpenFile(name, flag, perm) } // TestDecryptToUnwritableOutput checks that secret decrypt fails when // --output is a file that cannot be written, as writing into it directly // does, and leaves it as it was, although its directory can be written. func TestDecryptToUnwritableOutput(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.fs = &writeFailFs{Fs: cli.fs, path: existing} err := cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"), existing) require.ErrorIs(t, err, os.ErrPermission) content, err := afero.ReadFile(cli.fs, existing) require.NoError(t, err) require.Equal(t, "precious", string(content)) } // 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) } // TestDecryptThroughLinkAndParent checks that secret decrypt makes the file // --output names in the directory its path leads to when the path holds a // symbolic link to a directory followed by "..": dir/link/../sub/new.txt is // targetDir/sub/new.txt, and dir/sub, where removing "link/.." as text // leads, does not exist. func TestDecryptThroughLinkAndParent(t *testing.T) { t.Parallel() cli, dir := newCryptoTest(t) targetDir := t.TempDir() require.NoError(t, os.Mkdir(filepath.Join(targetDir, "linked"), 0o700)) require.NoError(t, os.Mkdir(filepath.Join(targetDir, "sub"), 0o700)) require.NoError(t, os.Symlink(filepath.Join(targetDir, "linked"), filepath.Join(dir, "link"))) // Not filepath.Join, which would remove "link/.." too output := dir + "/link/../sub/new.txt" require.NoError(t, cli.Decrypt("key", filepath.Join(dir, "plain.txt.age"), output)) content, err := afero.ReadFile(cli.fs, filepath.Join(targetDir, "sub", "new.txt")) require.NoError(t, err) require.Equal(t, plainText, string(content)) } // 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)) }