//nolint:testpackage // white-box test of unexported internals package cli import ( "errors" "path/filepath" "testing" "time" "github.com/awnumar/memguard" "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/stretchr/testify/require" "sneak.berlin/go/secret/internal/secret" "sneak.berlin/go/secret/internal/vault" ) // errPermissionDenied is returned by renameFailFs and removeAllFailFs in // place of a rename or removal that succeeds. var errPermissionDenied = errors.New("permission denied") // renameFailFs fails every Rename of one path, as a read-only parent // directory does. type renameFailFs struct { afero.Fs path string } func (f *renameFailFs) Rename(oldname, newname string) error { if oldname == f.path { return errPermissionDenied } return f.Fs.Rename(oldname, newname) } // removeAllFailFs fails every RemoveAll of an entry of the directory dir, // as a file in it that cannot be deleted does, and records that entry's // path in failed. type removeAllFailFs struct { afero.Fs dir string failed string } func (f *removeAllFailFs) RemoveAll(path string) error { if filepath.Dir(path) == f.dir { f.failed = path return errPermissionDenied } return f.Fs.RemoveAll(path) } // createCrossVaults creates in fs the vaults "work" and "default", where // "default" holds the secret "x" with the value "value". func createCrossVaults(t *testing.T, fs afero.Fs) { t.Helper() _, err := vault.CreateVault(fs, testStateDir, "work", testMnemonicBuffer(t), nil) require.NoError(t, err) source, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t), nil) require.NoError(t, err) err = source.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false) require.NoError(t, err) } // requireValue requires that the vault vaultName in fs holds secretName // with the value createCrossVaults stored. func requireValue(t *testing.T, fs afero.Fs, vaultName, secretName string) { t.Helper() vlt := vault.NewVault(fs, testStateDir, vaultName) vlt.Mnemonic = testMnemonicBuffer(t) value, err := vlt.GetSecret(secretName) require.NoError(t, err, vaultName+":"+secretName) defer value.Destroy() require.Equal(t, []byte("value"), value.Bytes()) } // TestCrossVaultMoveFailsWhenSourceRemains is a regression test for // https://git.eeqj.de/sneak/secret/issues/159, where a move between two // vaults whose source could not be removed reported the secret moved and // succeeded. It must fail, and the copy and the source must both be there. // The state directory lock refuses the wrapped filesystem, so the test // calls copySecretAndRemoveSource, which a move between vaults runs once it // holds the lock. func TestCrossVaultMoveFailsWhenSourceRemains(t *testing.T) { t.Parallel() base := afero.NewMemMapFs() createCrossVaults(t, base) instance := NewCLIInstanceWithStateDir(&renameFailFs{ Fs: base, path: testStateDir + "/vaults.d/default/secrets.d/x", }, testStateDir) source := vault.NewVault(instance.fs, testStateDir, "default") source.Mnemonic = testMnemonicBuffer(t) err := instance.copySecretAndRemoveSource(&cobra.Command{}, source, "x", vault.NewVault(instance.fs, testStateDir, "work"), "y", false) require.ErrorIs(t, err, errPermissionDenied) require.ErrorContains(t, err, "secret 'default:x' is copied with all "+ "1 version(s) to 'work:y', but the source remains") requireValue(t, base, "default", "x") requireValue(t, base, "work", "y") } // TestCrossVaultMoveFailsWhenOldFilesRemain makes the removal of the source // fail after the source has been moved out of its vault, when only the // temporary directory it was moved into cannot be deleted. The move must // fail, say that the secret is moved, and name that directory. func TestCrossVaultMoveFailsWhenOldFilesRemain(t *testing.T) { t.Parallel() base := afero.NewMemMapFs() createCrossVaults(t, base) sourceVaultDir := testStateDir + "/vaults.d/default" fs := &removeAllFailFs{Fs: base, dir: sourceVaultDir} instance := NewCLIInstanceWithStateDir(fs, testStateDir) source := vault.NewVault(fs, testStateDir, "default") source.Mnemonic = testMnemonicBuffer(t) err := instance.copySecretAndRemoveSource(&cobra.Command{}, source, "x", vault.NewVault(fs, testStateDir, "work"), "y", false) require.ErrorIs(t, err, errPermissionDenied) require.ErrorContains(t, err, "secret 'default:x' is moved with all "+ "1 version(s) to 'work:y', but its old files could not all be deleted") // The error names the directory that holds the old files require.NotEmpty(t, fs.failed) require.ErrorContains(t, err, fs.failed) oldFiles, err := afero.DirExists(base, filepath.Join(fs.failed, "x")) require.NoError(t, err) require.True(t, oldFiles) // The source vault no longer holds the secret remains, err := afero.DirExists(base, sourceVaultDir+"/secrets.d/x") require.NoError(t, err) require.False(t, remains) requireValue(t, base, "work", "y") } // TestCrossVaultMoveUnlocksBeforeLocking is a regression test for // https://git.eeqj.de/sneak/secret/issues/189: a move between two vaults // unlocked them while holding the state directory lock, and unlocking a // vault can ask for its passphrase, so every other command that changes // anything waited while the user answered. Here each vault has a passphrase // unlocker whose passphrase is the vault's name, and the move is given the // passphrase of only one of them. Run while the test holds the lock, it // must fail to unlock the other vault without waiting for the lock. // //nolint:paralleltest // times commands against the in-memory lock all tests share func TestCrossVaultMoveUnlocksBeforeLocking(t *testing.T) { fs := afero.NewMemMapFs() createCrossVaults(t, fs) vaults, err := vault.ListVaults(fs, testStateDir) require.NoError(t, err) for _, name := range vaults { vlt := vault.NewVault(fs, testStateDir, name) vlt.Mnemonic = testMnemonicBuffer(t) passphrase := memguard.NewBufferFromBytes([]byte(name)) _, err = vlt.CreatePassphraseUnlocker(passphrase) passphrase.Destroy() require.NoError(t, err) } // The move is from "default" to "work" for _, tc := range []struct { // failing is the vault that the move cannot unlock, given the // passphrase of the vault named given failing, given string }{ {"source", "work"}, {"destination", "default"}, } { t.Run(tc.failing, func(t *testing.T) { passphrase := memguard.NewBufferFromBytes([]byte(tc.given)) defer passphrase.Destroy() instance := NewCLIInstanceWithStateDir(fs, testStateDir) instance.UnlockPassphrase = passphrase release, err := vault.LockStateDir(fs, testStateDir) require.NoError(t, err) // Also frees a waiting move if the test fails, so that it // releases the lock the other tests use defer release() done := make(chan error, 1) go func() { done <- instance.MoveSecret(&cobra.Command{}, "default:x", "work:y", false) }() select { case err := <-done: require.ErrorIs(t, err, secret.ErrWrongPassphrase) require.ErrorContains(t, err, "failed to unlock "+tc.failing+" vault") case <-time.After(lockWait): t.Fatal("waited for the lock before unlocking the " + tc.failing + " vault") } }) } } // TestCrossVaultMoveChecksAgainOnceLocked runs a move between vaults, given // the mnemonic, as TestCommandsCheckAgainOnceLocked runs the commands in its // table: once the move waits for the state directory lock, the test removes // the source secret or the destination vault, or creates the destination // vault again from another mnemonic, and the move must then fail and change // nothing. // //nolint:paralleltest // waitingForLock sees any test's command waiting for the lock func TestCrossVaultMoveChecksAgainOnceLocked(t *testing.T) { mnemonic := testMnemonicBuffer(t) otherMnemonic := memguard.NewBufferFromBytes([]byte("legal winner thank " + "year wave sausage worth useful legal winner thank yellow")) t.Cleanup(otherMnemonic.Destroy) move := func(cli *Instance) error { cli.Mnemonic = mnemonic return cli.MoveSecret(cli.cmd, "work:test/secret", "other:moved", false) } t.Run("source removed", func(t *testing.T) { requireChecksAgainOnceLocked(t, move, func(fs afero.Fs) error { return fs.RemoveAll(testStateDir + "/vaults.d/work/secrets.d/test%secret") }, vault.ErrSecretNotFound) }) t.Run("destination vault removed", func(t *testing.T) { requireChecksAgainOnceLocked(t, move, func(fs afero.Fs) error { return fs.RemoveAll(testStateDir + "/vaults.d/other") }, vault.ErrVaultNotFound) }) t.Run("destination vault created again from another mnemonic", func(t *testing.T) { requireChecksAgainOnceLocked(t, move, func(fs afero.Fs) error { err := fs.RemoveAll(testStateDir + "/vaults.d/other") if err != nil { return err } _, err = vault.CreateVault(fs, testStateDir, "other", otherMnemonic, nil) return err }, errVaultCreatedAgain) }) } // TestCrossVaultMoveRefusesExistingSecretBeforeUnlocking checks that a move // between vaults onto a secret that exists, without --force, is refused // before either vault is unlocked, since unlocking can ask for a // passphrase. The vaults have no unlockers and the move is given no // mnemonic, so unlocking either vault fails. func TestCrossVaultMoveRefusesExistingSecretBeforeUnlocking(t *testing.T) { t.Parallel() fs := afero.NewMemMapFs() createCrossVaults(t, fs) work := vault.NewVault(fs, testStateDir, "work") work.Mnemonic = testMnemonicBuffer(t) err := work.AddSecret("y", memguard.NewBufferFromBytes([]byte("other")), false) require.NoError(t, err) instance := NewCLIInstanceWithStateDir(fs, testStateDir) err = instance.MoveSecret(&cobra.Command{}, "default:x", "work:y", false) require.ErrorIs(t, err, vault.ErrSecretExists) }