Files
secret/internal/cli/crypto_test.go
T
sneak bde833193c
check / check (push) Waiting to run
Write encrypt and decrypt output only once they succeed (closes #143)
`secret encrypt` and `secret decrypt` emptied the file named by
`--output` before reading any input, so a failure left it empty, and a
file given as both `--input` and `--output` was emptied before it was
read. They now write into a temporary file of mode 0600 next to it, or
next to the file it is a symbolic link to, and rename that over it only
on success, with the new `secret.WriteFileAtomicFunc`. An output file
that cannot be written still fails. Ctrl-C, SIGTERM or a hangup removes
the temporary file. A device or pipe, such as `/dev/null`, is written to
directly.

Judgement calls: the device and pipe case; a command started with
`nohup` still ignores a hangup.

Model: opus-5-5
2026-10-14 22:32:52 +00:00

305 lines
9.7 KiB
Go

//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))
}