//nolint:testpackage // white-box test of unexported internals package cli import ( "errors" "path/filepath" "sync" "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 starts a move between two vaults, // given the mnemonic, while the test holds the state directory lock. Once // the move waits for the lock, the test removes what another command could // have removed in the meantime, then releases the lock. The move must fail // with the error that says what is missing. // //nolint:paralleltest // waitingForLock sees any test's command waiting for the lock func TestCrossVaultMoveChecksAgainOnceLocked(t *testing.T) { for _, tc := range []struct { name string // removed is the directory, under the state directory, that the // test removes removed string want error }{ {"source", "/vaults.d/default/secrets.d/x", vault.ErrSecretNotFound}, {"destination vault", "/vaults.d/work", vault.ErrVaultNotFound}, } { t.Run(tc.name, func(t *testing.T) { fs := afero.NewMemMapFs() createCrossVaults(t, fs) release, err := vault.LockStateDir(fs, testStateDir) require.NoError(t, err) // Released at most once, and also if the test fails while // holding it, so that later tests can take it release = sync.OnceFunc(release) defer release() instance := NewCLIInstanceWithStateDir(fs, testStateDir) instance.Mnemonic = testMnemonicBuffer(t) done := make(chan error, 1) go func() { done <- instance.MoveSecret(&cobra.Command{}, "default:x", "work:y", false) }() timeout := time.After(lockWait) for !waitingForLock() { select { case err := <-done: t.Fatalf("finished while the lock was held, with error %v", err) case <-timeout: t.Fatal("never waited for the lock") case <-time.After(time.Millisecond): } } require.NoError(t, fs.RemoveAll(testStateDir+tc.removed)) release() select { case err := <-done: require.ErrorIs(t, err, tc.want) case <-time.After(lockWait): t.Fatal("did not finish once the lock was released") } }) } }