check / check (push) Waiting to run
init and vault create put the mnemonic into the process environment for vault.CreateVault to read back, so every program they ran, gpg included, inherited it, and SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE were read at 13 places and never unset. Each command that may need them now reads both once, in its RunE, into locked buffers on the CLI Instance, and unsets them at once. The buffers are passed down: vault.CreateVault takes the mnemonic, a Vault carries Mnemonic and UnlockPassphrase, and the PGP, keychain and Secure Enclave unlocker constructors take both. Nothing below the command reads the environment. README warns against both variables. Model: opus-5-5
379 lines
12 KiB
Go
379 lines
12 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"fmt"
|
|
"maps"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"git.eeqj.de/sneak/secret/internal/cli"
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"git.eeqj.de/sneak/secret/internal/vault"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
"github.com/spf13/cobra"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const (
|
|
// testStateDir is the in-memory state directory of the test vaults.
|
|
testStateDir = "/test/state"
|
|
|
|
// testPassphrase protects the passphrase unlocker of each test vault.
|
|
testPassphrase = "test-passphrase"
|
|
|
|
// testVersion is a version name in the format the vault uses.
|
|
testVersion = "20260101.001"
|
|
|
|
// missingFile is an import source that does not exist, so an import
|
|
// that opened it before checking the name would fail with another error.
|
|
missingFile = "/no/such/file"
|
|
)
|
|
|
|
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
|
|
// destroyed when the test ends.
|
|
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
|
|
t.Helper()
|
|
|
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
|
t.Cleanup(mnemonic.Destroy)
|
|
|
|
return mnemonic
|
|
}
|
|
|
|
// The state directory newTwoVaultFs copies, recorded by snapshotStateDir.
|
|
// Creating a passphrase unlocker is slow by design, so the vaults are made
|
|
// once, by the first test that needs them.
|
|
//
|
|
//nolint:gochecknoglobals // shared by the tests that use newTwoVaultFs
|
|
var (
|
|
twoVaultsOnce sync.Once
|
|
twoVaults map[string]string
|
|
)
|
|
|
|
// newTwoVaultFs returns an in-memory filesystem holding the vaults "work"
|
|
// and "default", the current one. Each holds the secret "x" and a
|
|
// passphrase unlocker, so both secrets.d and unlockers.d have contents.
|
|
// Every call returns a new copy of the same vaults.
|
|
//
|
|
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
|
|
func newTwoVaultFs(t *testing.T) afero.Fs {
|
|
t.Helper()
|
|
|
|
twoVaultsOnce.Do(func() {
|
|
fs := afero.NewMemMapFs()
|
|
mnemonic := testMnemonicBuffer(t)
|
|
|
|
for _, name := range []string{"work", "default"} {
|
|
vlt, err := vault.CreateVault(fs, testStateDir, name, mnemonic)
|
|
require.NoError(t, err)
|
|
|
|
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
|
require.NoError(t, err)
|
|
|
|
_, err = vlt.CreatePassphraseUnlocker(
|
|
memguard.NewBufferFromBytes([]byte(testPassphrase)))
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
twoVaults = snapshotStateDir(t, fs)
|
|
})
|
|
|
|
require.NotNil(t, twoVaults, "making the vaults failed in an earlier test")
|
|
|
|
return newFsFromSnapshot(t, twoVaults)
|
|
}
|
|
|
|
// snapshotStateDir maps every file under the state directory to its
|
|
// contents, and every directory, written with a trailing "/", to "". Two
|
|
// snapshots are equal only if nothing in it was added, removed or changed.
|
|
func snapshotStateDir(t *testing.T, fs afero.Fs) map[string]string {
|
|
t.Helper()
|
|
|
|
tree := map[string]string{}
|
|
|
|
err := afero.Walk(fs, testStateDir, func(
|
|
path string, info os.FileInfo, err error,
|
|
) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if info.IsDir() {
|
|
tree[path+"/"] = ""
|
|
|
|
return nil
|
|
}
|
|
|
|
content, err := afero.ReadFile(fs, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tree[path] = string(content)
|
|
|
|
return nil
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
return tree
|
|
}
|
|
|
|
// newFsFromSnapshot returns a new in-memory filesystem holding exactly the
|
|
// directories and files recorded by snapshotStateDir.
|
|
//
|
|
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
|
|
func newFsFromSnapshot(t *testing.T, tree map[string]string) afero.Fs {
|
|
t.Helper()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// In sorted order every directory comes before its contents.
|
|
for _, path := range slices.Sorted(maps.Keys(tree)) {
|
|
dir, isDir := strings.CutSuffix(path, "/")
|
|
if isDir {
|
|
require.NoError(t, fs.MkdirAll(dir, secret.DirPerms))
|
|
|
|
continue
|
|
}
|
|
|
|
err := afero.WriteFile(fs, path, []byte(tree[path]), secret.FilePerms)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
return fs
|
|
}
|
|
|
|
// requireRejectedAndUnchanged runs a command on a copy of the state
|
|
// directory recorded in before. It requires an error with exactly the
|
|
// message of want, so that a later check rejecting the argument does not
|
|
// count, and everything under the state directory as it was: the error
|
|
// alone proves nothing, since it could come after the vault had already
|
|
// been deleted.
|
|
func requireRejectedAndUnchanged(
|
|
t *testing.T, before map[string]string, want error,
|
|
run func(c *cli.Instance) error,
|
|
) {
|
|
t.Helper()
|
|
|
|
fs := newFsFromSnapshot(t, before)
|
|
|
|
err := run(cli.NewCLIInstanceWithStateDir(fs, testStateDir))
|
|
|
|
require.Equal(t, before, snapshotStateDir(t, fs))
|
|
require.EqualError(t, err, want.Error())
|
|
}
|
|
|
|
// TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for
|
|
// https://git.eeqj.de/sneak/secret/issues/33, where `secret rm ..` deleted
|
|
// the whole vault, and `secret rm .` or `secret rm ""` every secret in it.
|
|
// Moves and imports use --force, so that only the name check stands in
|
|
// the way.
|
|
//
|
|
//nolint:paralleltest // the cases share cmd
|
|
func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
|
|
// Creating a passphrase unlocker is slow by design, so the vaults are
|
|
// created once and each case runs on its own copy of them.
|
|
before := snapshotStateDir(t, newTwoVaultFs(t))
|
|
|
|
vaultDir := testStateDir + "/vaults.d/default"
|
|
require.Contains(t, before, vaultDir+"/secrets.d/x/")
|
|
require.Contains(t, before, vaultDir+"/unlockers.d/passphrase/")
|
|
require.Equal(t, "default", before[testStateDir+"/currentvault"])
|
|
|
|
cmd := &cobra.Command{}
|
|
|
|
tests := []struct {
|
|
command string
|
|
rejected string // the secret name the command must reject
|
|
run func(c *cli.Instance) error
|
|
}{
|
|
{"rm ..", "..", func(c *cli.Instance) error {
|
|
return c.RemoveSecret(cmd, "..", false)
|
|
}},
|
|
{"rm .", ".", func(c *cli.Instance) error {
|
|
return c.RemoveSecret(cmd, ".", false)
|
|
}},
|
|
{`rm ""`, "", func(c *cli.Instance) error {
|
|
return c.RemoveSecret(cmd, "", false)
|
|
}},
|
|
{"rm ../../etc", "../../etc", func(c *cli.Instance) error {
|
|
return c.RemoveSecret(cmd, "../../etc", false)
|
|
}},
|
|
{"mv --force .. x", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "..", "x", true)
|
|
}},
|
|
{"mv --force x ..", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "x", "..", true)
|
|
}},
|
|
{`mv --force x ""`, "", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "x", "", true)
|
|
}},
|
|
// "work" is not the current vault: a move within it must not
|
|
// select it when a name is rejected.
|
|
{"mv --force work:.. work:x", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "work:..", "work:x", true)
|
|
}},
|
|
{"mv --force work:x work:..", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "work:x", "work:..", true)
|
|
}},
|
|
{"mv --force default:.. work", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "default:..", "work", true)
|
|
}},
|
|
{"mv --force default:.. work:y", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "default:..", "work:y", true)
|
|
}},
|
|
{"mv --force default:x work:..", "..", func(c *cli.Instance) error {
|
|
return c.MoveSecret(cmd, "default:x", "work:..", true)
|
|
}},
|
|
{"import --force ..", "..", func(c *cli.Instance) error {
|
|
return c.ImportSecret(cmd, "..", missingFile, true)
|
|
}},
|
|
{"import --force .", ".", func(c *cli.Instance) error {
|
|
return c.ImportSecret(cmd, ".", missingFile, true)
|
|
}},
|
|
{"import --force ../../etc", "../../etc", func(c *cli.Instance) error {
|
|
return c.ImportSecret(cmd, "../../etc", missingFile, true)
|
|
}},
|
|
{"version list ..", "..", func(c *cli.Instance) error {
|
|
return c.ListVersions(cmd, "..")
|
|
}},
|
|
{"version promote ..", "..", func(c *cli.Instance) error {
|
|
return c.PromoteVersion(cmd, "..", testVersion)
|
|
}},
|
|
{"version rm ..", "..", func(c *cli.Instance) error {
|
|
return c.RemoveVersion(cmd, "..", testVersion)
|
|
}},
|
|
{"encrypt ..", "..", func(c *cli.Instance) error {
|
|
return c.Encrypt("..", "", "")
|
|
}},
|
|
{"decrypt ..", "..", func(c *cli.Instance) error {
|
|
return c.Decrypt("..", "", "")
|
|
}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.command, func(t *testing.T) {
|
|
requireRejectedAndUnchanged(t, before, vault.ValidateSecretName(tt.rejected), tt.run)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestInvalidVersionLeavesVaultsUnchanged is a regression test for
|
|
// https://git.eeqj.de/sneak/secret/issues/67, where
|
|
// `secret version rm x ../../..` deleted the whole vault,
|
|
// `secret version rm x ..` the secret x, and `secret version rm x .` or
|
|
// `secret version rm x ""` every version of x. A version argument is
|
|
// accepted only if it is one of the versions `secret version list` lists.
|
|
//
|
|
//nolint:paralleltest // the cases share cmd
|
|
func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
|
|
before := snapshotStateDir(t, newTwoVaultFs(t))
|
|
|
|
cmd := &cobra.Command{}
|
|
|
|
commands := []struct {
|
|
command string
|
|
run func(c *cli.Instance, version string) error
|
|
}{
|
|
{"version rm x", func(c *cli.Instance, version string) error {
|
|
return c.RemoveVersion(cmd, "x", version)
|
|
}},
|
|
{"version promote x", func(c *cli.Instance, version string) error {
|
|
return c.PromoteVersion(cmd, "x", version)
|
|
}},
|
|
{"get x --version", func(c *cli.Instance, version string) error {
|
|
return c.GetSecretWithVersion(cmd, "x", version)
|
|
}},
|
|
}
|
|
|
|
for _, tt := range commands {
|
|
for _, version := range []string{"", ".", "..", "../../..", "a/b"} {
|
|
t.Run(fmt.Sprintf("%s %q", tt.command, version), func(t *testing.T) {
|
|
want := fmt.Errorf("version '%s' %w '%s'",
|
|
version, vault.ErrVersionNotFound, "x")
|
|
requireRejectedAndUnchanged(t, before, want,
|
|
func(c *cli.Instance) error { return tt.run(c, version) })
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRemoveVersionRemovesOnlyThatVersion checks that `secret version rm`
|
|
// with a version that is not the current one removes that version and
|
|
// changes nothing else.
|
|
func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := newTwoVaultFs(t)
|
|
|
|
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
|
require.NoError(t, err)
|
|
|
|
vlt.Mnemonic = testMnemonicBuffer(t)
|
|
|
|
// A second version of "x" becomes the current one.
|
|
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("new")), true)
|
|
require.NoError(t, err)
|
|
|
|
secretDir := testStateDir + "/vaults.d/default/secrets.d/x"
|
|
versions, err := secret.ListVersions(fs, secretDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, versions, 2)
|
|
|
|
// ListVersions lists the newest version first.
|
|
oldDir := secretDir + "/versions/" + versions[1] + "/"
|
|
before := snapshotStateDir(t, fs)
|
|
require.Contains(t, before, oldDir)
|
|
|
|
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
err = c.RemoveVersion(&cobra.Command{}, "x", versions[1])
|
|
require.NoError(t, err)
|
|
|
|
// Expected: the state as before without everything under oldDir.
|
|
want := map[string]string{}
|
|
|
|
for path, content := range before {
|
|
if !strings.HasPrefix(path, oldDir) {
|
|
want[path] = content
|
|
}
|
|
}
|
|
|
|
require.Equal(t, want, snapshotStateDir(t, fs))
|
|
}
|
|
|
|
// TestMoveToVaultNameRenamesInCurrentVault checks that `secret mv x work`,
|
|
// where "work" is also the name of a vault, renames the secret "x" to "work"
|
|
// in the current vault and changes nothing else.
|
|
func TestMoveToVaultNameRenamesInCurrentVault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
before := snapshotStateDir(t, newTwoVaultFs(t))
|
|
fs := newFsFromSnapshot(t, before)
|
|
|
|
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
err := c.MoveSecret(&cobra.Command{}, "x", "work", false)
|
|
require.NoError(t, err)
|
|
|
|
// Expected: the state as before, with everything under the current
|
|
// vault's secrets.d/x/ now under secrets.d/work/.
|
|
oldDir := testStateDir + "/vaults.d/default/secrets.d/x/"
|
|
newDir := testStateDir + "/vaults.d/default/secrets.d/work/"
|
|
want := map[string]string{}
|
|
|
|
for path, content := range before {
|
|
rest, found := strings.CutPrefix(path, oldDir)
|
|
if found {
|
|
path = newDir + rest
|
|
}
|
|
|
|
want[path] = content
|
|
}
|
|
|
|
require.Contains(t, want, newDir)
|
|
require.Equal(t, want, snapshotStateDir(t, fs))
|
|
}
|