check / check (push) Waiting to run
`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, using the new `secret.WriteFileAtomicFunc`, which `secret.WriteFileAtomic` now calls. Ctrl-C or SIGTERM removes the temporary file. A device or pipe given as `--output`, such as `/dev/null`, is written to directly. Judgement call: the device and pipe case goes beyond the plan. Judgement call: a link to a file that does not exist yet is replaced. Model: opus-5-5
192 lines
5.8 KiB
Go
192 lines
5.8 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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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))
|
|
}
|