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) }) }) } } } // TestInvalidVaultNameLeavesStateUnchanged is a regression test for // https://git.eeqj.de/sneak/secret/issues/68, where // `secret vault import ..` wrote a long-term key and an unlocker into the // state directory itself, and `secret vault select ..` made it the current // vault. Each command that takes a vault name must reject an invalid one // before building a path from it. The instance is given the mnemonic and // the passphrase, and moves and removals use --force, so that only the name // check stands in the way. // //nolint:paralleltest // the cases share cmd func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) { before := snapshotStateDir(t, newTwoVaultFs(t)) mnemonic := testMnemonicBuffer(t) passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase)) t.Cleanup(passphrase.Destroy) cmd := &cobra.Command{} // Each command is a format with %q where the vault name goes. commands := []struct { command string run func(c *cli.Instance, name string) error }{ {"vault create %q", func(c *cli.Instance, name string) error { return c.CreateVault(cmd, name) }}, {"vault import %q", func(c *cli.Instance, name string) error { return c.VaultImport(cmd, name) }}, {"vault select %q", func(c *cli.Instance, name string) error { return c.SelectVault(cmd, name) }}, {"vault remove --force %q", func(c *cli.Instance, name string) error { return c.RemoveVault(cmd, name, true) }}, {"mv --force %q:x work:x", func(c *cli.Instance, name string) error { return c.MoveSecret(cmd, name+":x", "work:x", true) }}, {"mv --force default:x %q:x", func(c *cli.Instance, name string) error { return c.MoveSecret(cmd, "default:x", name+":x", true) }}, } for _, tt := range commands { for _, name := range []string{"", ".", "..", "a/b"} { t.Run(fmt.Sprintf(tt.command, name), func(t *testing.T) { requireRejectedAndUnchanged(t, before, vault.ValidateVaultName(name), func(c *cli.Instance) error { c.Mnemonic = mnemonic c.UnlockPassphrase = passphrase return tt.run(c, name) }) }) } } } // 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)) }