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, refusing an existing destination secret without --force, unlocks both vaults, and only then takes the lock. Under the lock, it checks that the destination vault still has the long-term key it unlocked, and copySecretAndRemoveSource checks the move again before it copies. A move within a vault takes the lock in moveSecretWithinVault, so MoveSecret takes none itself. The error for a vault created again with another key, shared with secret unlocker add, now ends "nothing was changed". Model: opus-5-5
316 lines
10 KiB
Go
316 lines
10 KiB
Go
//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) {
|
|
// Set by the move, which has finished once
|
|
// requireChecksAgainOnceLocked returns
|
|
var moveErr error
|
|
|
|
requireChecksAgainOnceLocked(t, func(cli *Instance) error {
|
|
moveErr = move(cli)
|
|
|
|
return moveErr
|
|
}, 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)
|
|
|
|
// secret unlocker add fails with the same error, so its message
|
|
// must fit a move too
|
|
require.EqualError(t, moveErr, "vault 'other' was removed and "+
|
|
"created again with another key while it was being unlocked; "+
|
|
"nothing was changed")
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
}
|