check / check (push) Waiting to run
A move between two vaults unlocked them while holding the state directory lock, and unlocking can ask for a passphrase, so every other changing command waited at that prompt. moveSecretCrossVault now checks the move, unlocks both vaults, and only then takes the lock. Under the lock, copySecretAndRemoveSource checks again that the source and the destination vault still exist, then copies. A move within a vault takes the lock in moveSecretWithinVault, so MoveSecret takes none itself. The two tests of a source that cannot be removed now call copySecretAndRemoveSource, since the lock refuses their wrapped filesystems. Model: opus-5-5
295 lines
8.9 KiB
Go
295 lines
8.9 KiB
Go
//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)
|
|
|
|
for _, name := range []string{"default", "work"} {
|
|
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")
|
|
}
|
|
})
|
|
}
|
|
}
|