package cli_test import ( "bytes" "io" "maps" "os" "slices" "strings" "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" ) // TestCreateExistingVaultChangesNothing is a regression test for // https://git.eeqj.de/sneak/secret/issues/74, where running `secret init` // a second time, or `secret vault create` with the name of an existing // vault, replaced that vault's keys, so that none of its secrets could be // decrypted any more. Each must refuse, change nothing, and leave every // vault's secret readable through its passphrase unlocker. // //nolint:paralleltest // the cases share cmd func TestCreateExistingVaultChangesNothing(t *testing.T) { mnemonic := testMnemonicBuffer(t) passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase)) t.Cleanup(passphrase.Destroy) // newCLI returns an instance on fs given the mnemonic and the unlock // passphrase, as from the environment newCLI := func(fs afero.Fs) *cli.Instance { c := cli.NewCLIInstanceWithStateDir(fs, testStateDir) c.Mnemonic = mnemonic c.UnlockPassphrase = passphrase return c } // `secret init`, `secret vault create work`, `secret vault select // default`, and the secret "x" in each vault. "work" is then not the // current vault, which creating it again must not change. fs := afero.NewMemMapFs() c := newCLI(fs) cmd := &cobra.Command{} require.NoError(t, c.Init(cmd)) require.NoError(t, c.CreateVault(cmd, "work")) require.NoError(t, c.SelectVault(cmd, "default")) vaults, err := vault.ListVaults(fs, testStateDir) require.NoError(t, err) require.Len(t, vaults, 2) for _, name := range vaults { value := memguard.NewBufferFromBytes([]byte("value")) err := vault.NewVault(fs, testStateDir, name).AddSecret("x", value, false) require.NoError(t, err) } before := snapshotStateDir(t, fs) tests := []struct { command string want string run func(c *cli.Instance) error }{ { "init", "failed to create default vault: vault default already exists", func(c *cli.Instance) error { return c.Init(cmd) }, }, { "vault create default", "vault default already exists", func(c *cli.Instance) error { return c.CreateVault(cmd, "default") }, }, { "vault create work", "vault work already exists", func(c *cli.Instance) error { return c.CreateVault(cmd, "work") }, }, } for _, tt := range tests { t.Run(tt.command, func(t *testing.T) { fs := newFsFromSnapshot(t, before) err := tt.run(newCLI(fs)) require.EqualError(t, err, tt.want) require.Equal(t, before, snapshotStateDir(t, fs)) }) } // Every case left the state directory exactly as recorded in before, so // reading each vault's secret once from it shows that it still decrypts // after each case. Without the mnemonic, reading a secret goes through // the vault's passphrase unlocker, which is slow. for _, name := range vaults { vlt := vault.NewVault(fs, testStateDir, name) vlt.UnlockPassphrase = passphrase value, err := vlt.GetSecret("x") require.NoError(t, err) unchanged := bytes.Equal([]byte("value"), value.Bytes()) value.Destroy() require.True(t, unchanged, "vault %q kept its secret", name) } } // TestVaultCreationLeavesNoSecretInEnvironment is a regression test for // https://git.eeqj.de/sneak/secret/issues/60, where `secret init` and // `secret vault create` put the mnemonic into the process environment, // which every program they ran inherited, and SB_SECRET_MNEMONIC and // SB_UNLOCK_PASSPHRASE were never unset. Each command, given both, must // leave neither in the environment. func TestVaultCreationLeavesNoSecretInEnvironment(t *testing.T) { t.Setenv(secret.EnvStateDir, t.TempDir()) run := func(args ...string) { t.Setenv(secret.EnvMnemonic, testMnemonic) t.Setenv(secret.EnvUnlockPassphrase, testPassphrase) // With no terminal to prompt on, this succeeds only if the command // read both variables _, err := cli.ExecuteCommandInProcess(args, "", nil) require.NoError(t, err) for _, name := range []string{secret.EnvMnemonic, secret.EnvUnlockPassphrase} { _, set := os.LookupEnv(name) require.False(t, set, "%s is set after %v", name, args) } } run("init") run("vault", "create", "work") } // TestStopAtPassphrasePromptLeavesNothing is a regression test for the // review of https://git.eeqj.de/sneak/secret/pulls/82: `secret init` or // `secret vault create` stopped at the passphrase prompt left a vault with // no unlocker, which neither command would then create again. Each must ask // for the passphrase before writing anything. // //nolint:paralleltest // the cases share cmd func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) { mnemonic := testMnemonicBuffer(t) // An empty state directory for `secret init`, and one holding the vault // "default" for `secret vault create work`. empty := afero.NewMemMapFs() require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms)) withDefault := afero.NewMemMapFs() _, err := vault.CreateVault(withDefault, testStateDir, "default", mnemonic, nil) require.NoError(t, err) cmd := &cobra.Command{} tests := []struct { command string fs afero.Fs run func(c *cli.Instance) error }{ { "init", empty, func(c *cli.Instance) error { return c.Init(cmd) }, }, { "vault create work", withDefault, func(c *cli.Instance) error { return c.CreateVault(cmd, "work") }, }, } for _, tt := range tests { t.Run(tt.command, func(t *testing.T) { before := snapshotStateDir(t, tt.fs) // Given no unlock passphrase, both commands prompt for it, which // fails because the tests do not run in a terminal. c := cli.NewCLIInstanceWithStateDir(tt.fs, testStateDir) c.Mnemonic = mnemonic err := tt.run(c) require.ErrorContains(t, err, "failed to read passphrase") require.Equal(t, before, snapshotStateDir(t, tt.fs)) }) } } // TestStopDuringCreateLeavesWholeVaultOrNone is a regression test for // https://git.eeqj.de/sneak/secret/issues/105: `secret init` or `secret vault // create` killed after the passphrase prompt but before the unlocker was // written left a vault with no unlocker, which neither command would then // create again. After the prompt, each command changes the state directory // only through vault.CreateVault. The test makes that call as the command // does and records the state directory before each change it makes, and once // after it returns: what a stop at that point leaves. Each must hold either // no vault, and not name it current, or exactly the finished vault, which // opens with the passphrase through its current unlocker. The command run // again after a stop first takes the lock, which must delete what the stop // left under a temporary name. Running the command is slow, so it runs once // on each different state the lock leaves, and must create the vault there, // or refuse the one there. // //nolint:paralleltest // commands on the in-memory filesystem share one lock func TestStopDuringCreateLeavesWholeVaultOrNone(t *testing.T) { mnemonic := testMnemonicBuffer(t) passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase)) t.Cleanup(passphrase.Destroy) cmd := &cobra.Command{} cmd.SetOut(io.Discard) t.Run("init", func(t *testing.T) { // From an empty state directory fs := afero.NewMemMapFs() require.NoError(t, fs.MkdirAll(testStateDir, secret.DirPerms)) requireStopsLeaveWholeVaultOrNone(t, fs, "default", "failed to create default vault: vault default already exists", mnemonic, passphrase, func(c *cli.Instance) error { return c.Init(cmd) }) }) t.Run("vault create work", func(t *testing.T) { // From a state directory holding the vault "default" fs := afero.NewMemMapFs() _, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil) require.NoError(t, err) requireStopsLeaveWholeVaultOrNone(t, fs, "work", "vault work already exists", mnemonic, passphrase, func(c *cli.Instance) error { return c.CreateVault(cmd, "work") }) }) } // requireStopsLeaveWholeVaultOrNone checks, as // TestStopDuringCreateLeavesWholeVaultOrNone describes, the stops of the // command run, creating the vault name on fs with mnemonic and passphrase. // Run again where the vault is there, the command must fail with exists. func requireStopsLeaveWholeVaultOrNone( t *testing.T, fs afero.Fs, name, exists string, mnemonic, passphrase *memguard.LockedBuffer, run func(c *cli.Instance) error, ) { t.Helper() var stops []map[string]string record := func() { stops = append(stops, snapshotStateDir(t, fs)) } _, err := vault.CreateVault(hookFs{Fs: fs, before: record}, testStateDir, name, mnemonic, passphrase) require.NoError(t, err) record() vaultDir := testStateDir + "/vaults.d/" + name require.NotContains(t, stops[0], vaultDir+"/", "no stop before the vault") finished := entriesUnder(stops[len(stops)-1], vaultDir) opener := vault.NewVault(fs, testStateDir, name) opener.UnlockPassphrase = passphrase key, err := opener.UnlockVault() require.NoError(t, err) require.Equal(t, finished[vaultDir+"/pub.age"], key.Recipient().String()) // Each different state the command run again finds once it holds the lock var locked []map[string]string for i, stop := range stops { if _, there := stop[vaultDir+"/"]; there { require.Equal(t, finished, entriesUnder(stop, vaultDir), "stop %d left a partial vault", i) } else { require.NotEqual(t, name, stop[testStateDir+"/currentvault"], "stop %d made a missing vault current", i) } stopped := newFsFromSnapshot(t, stop) release, err := vault.LockStateDir(stopped, testStateDir) require.NoError(t, err) release() state := snapshotStateDir(t, stopped) for path := range state { require.NotContains(t, path, ".tmp-", "stop %d", i) } if !slices.ContainsFunc(locked, func(s map[string]string) bool { return maps.Equal(s, state) }) { locked = append(locked, state) } } for _, state := range locked { c := cli.NewCLIInstanceWithStateDir(newFsFromSnapshot(t, state), testStateDir) c.Mnemonic = mnemonic c.UnlockPassphrase = passphrase if _, there := state[vaultDir+"/"]; there { require.EqualError(t, run(c), exists) } else { require.NoError(t, run(c)) } } } // entriesUnder returns the entries of a tree recorded by snapshotStateDir // that are under dir. func entriesUnder(tree map[string]string, dir string) map[string]string { entries := map[string]string{} for path, content := range tree { if strings.HasPrefix(path, dir+"/") { entries[path] = content } } return entries } // hookFs passes every call through to Fs, but first calls before for each // call that can change the filesystem. type hookFs struct { afero.Fs before func() } //nolint:ireturn // implements afero.Fs func (h hookFs) Create(name string) (afero.File, error) { h.before() return h.Fs.Create(name) } //nolint:ireturn // implements afero.Fs func (h hookFs) OpenFile( name string, flag int, perm os.FileMode, ) (afero.File, error) { h.before() return h.Fs.OpenFile(name, flag, perm) } func (h hookFs) Mkdir(name string, perm os.FileMode) error { h.before() return h.Fs.Mkdir(name, perm) } func (h hookFs) MkdirAll(path string, perm os.FileMode) error { h.before() return h.Fs.MkdirAll(path, perm) } func (h hookFs) Remove(name string) error { h.before() return h.Fs.Remove(name) } func (h hookFs) RemoveAll(path string) error { h.before() return h.Fs.RemoveAll(path) } func (h hookFs) Rename(oldname, newname string) error { h.before() return h.Fs.Rename(oldname, newname) }