check / check (push) Waiting to run
init, vault create, unlocker add, and add, import and generate secret with --force asked for the mnemonic or passphrase, or unlocked the vault, while holding the state directory lock, so every other command that changes anything waited at the prompt. They now ask and unlock first and lock after. What they checked before asking is checked again under the lock: unlocker add fails if the vault was removed meanwhile and refuses a GPG key added meanwhile, and a forced add fails if another command created the secret meanwhile, rather than ask under the lock. secret.CreatePGPUnlocker now takes the vault, already unlocked. Not done: secret move between vaults still unlocks both under the lock; the issue does not name it. Model: opus-5-5
826 lines
24 KiB
Go
826 lines
24 KiB
Go
//nolint:testpackage // sets the unexported fields of Instance
|
|
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
"github.com/spf13/cobra"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/secret/internal/secret"
|
|
"sneak.berlin/go/secret/internal/vault"
|
|
)
|
|
|
|
const (
|
|
// lockWait is how long a test waits for something that must happen
|
|
// once the lock is free.
|
|
lockWait = 10 * time.Second
|
|
|
|
// testPassphrase protects the passphrase unlockers the tests create.
|
|
testPassphrase = "test-passphrase"
|
|
|
|
// testInput is a file outside the state directory that commands read.
|
|
testInput = "/input"
|
|
)
|
|
|
|
// lockInBackground starts taking the state directory lock and returns a
|
|
// channel that delivers the function releasing it once it has been taken.
|
|
func lockInBackground(t *testing.T, fs afero.Fs) <-chan func() {
|
|
t.Helper()
|
|
|
|
taken := make(chan func(), 1)
|
|
|
|
go func() {
|
|
release, err := vault.LockStateDir(fs, testStateDir)
|
|
if assert.NoError(t, err) {
|
|
taken <- release
|
|
}
|
|
}()
|
|
|
|
return taken
|
|
}
|
|
|
|
// addAtOnce runs one add of the secret name per value, all at once, and
|
|
// returns their errors. Each add is given mnemonic, which a forced add
|
|
// needs.
|
|
func addAtOnce(
|
|
fs afero.Fs, stateDir, name string, force bool, values []string,
|
|
mnemonic *memguard.LockedBuffer,
|
|
) []error {
|
|
errs := make(chan error, len(values))
|
|
|
|
for _, value := range values {
|
|
go func() {
|
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
|
cli.Mnemonic = mnemonic
|
|
cli.cmd = &cobra.Command{}
|
|
cli.cmd.SetIn(strings.NewReader(value))
|
|
|
|
errs <- cli.AddSecret(name, force)
|
|
}()
|
|
}
|
|
|
|
results := make([]error, 0, len(values))
|
|
for range values {
|
|
results = append(results, <-errs)
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// numbered returns count distinct values starting with prefix.
|
|
func numbered(prefix string, count int) []string {
|
|
values := make([]string, 0, count)
|
|
for i := range count {
|
|
values = append(values, prefix+"-"+strconv.Itoa(i))
|
|
}
|
|
|
|
return values
|
|
}
|
|
|
|
// TestConcurrentAddsKeepEveryVersion runs adds of one secret at once, on
|
|
// the in-memory and on the real filesystem. Without the state directory
|
|
// lock, adds of a new secret all find it absent and replace each other, and
|
|
// forced adds read the same highest version number and overwrite each
|
|
// other's version. With it they behave as if run one after another.
|
|
func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
mnemonic := testMnemonicBuffer(t)
|
|
|
|
const adds = 8
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
fs afero.Fs
|
|
stateDir string
|
|
}{
|
|
{"memory", afero.NewMemMapFs(), testStateDir},
|
|
{"real", afero.NewOsFs(), t.TempDir()},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic, nil)
|
|
require.NoError(t, err)
|
|
|
|
// One add creates the secret; the others find that it exists
|
|
created := 0
|
|
|
|
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", false,
|
|
numbered("create", adds), mnemonic) {
|
|
if err == nil {
|
|
created++
|
|
} else {
|
|
require.ErrorIs(t, err, vault.ErrSecretExists)
|
|
}
|
|
}
|
|
|
|
require.Equal(t, 1, created, "exactly one add creates the secret")
|
|
|
|
// Every forced add stores a version of its own
|
|
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", true,
|
|
numbered("force", adds), mnemonic) {
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
vlt, err := vault.GetCurrentVault(tc.fs, tc.stateDir)
|
|
require.NoError(t, err)
|
|
|
|
vlt.Mnemonic = mnemonic
|
|
|
|
vaultDir, err := vlt.GetDirectory()
|
|
require.NoError(t, err)
|
|
|
|
versions, err := secret.ListVersions(tc.fs,
|
|
filepath.Join(vaultDir, "secrets.d", "shared"))
|
|
require.NoError(t, err)
|
|
require.Len(t, versions, adds+1, "one version per successful add")
|
|
|
|
values := make(map[string]bool, len(versions))
|
|
|
|
for _, version := range versions {
|
|
value, err := vlt.GetSecretVersion("shared", version)
|
|
require.NoError(t, err)
|
|
|
|
values[string(value.Bytes())] = true
|
|
value.Destroy()
|
|
}
|
|
|
|
assert.Len(t, values, adds+1, "every add stored its own value")
|
|
})
|
|
}
|
|
}
|
|
|
|
// readNotifier passes reads through to Reader and closes reading at the
|
|
// first one.
|
|
type readNotifier struct {
|
|
io.Reader
|
|
|
|
reading chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
func (r *readNotifier) Read(p []byte) (int, error) {
|
|
r.once.Do(func() { close(r.reading) })
|
|
|
|
return r.Reader.Read(p)
|
|
}
|
|
|
|
// TestEncryptPipedIntoAdd runs `secret encrypt key | secret add name` in
|
|
// one process, starting encrypt once add is reading its input. Had add
|
|
// taken the state directory lock before reading, it would hold the lock
|
|
// while waiting for encrypt's output, and encrypt would wait for the lock
|
|
// to store its key: neither would finish.
|
|
//
|
|
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
|
func TestEncryptPipedIntoAdd(t *testing.T) {
|
|
fs := afero.NewMemMapFs()
|
|
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t), nil)
|
|
require.NoError(t, err)
|
|
require.NoError(t, afero.WriteFile(fs, testInput, []byte("piped"), 0o600))
|
|
|
|
pipeReader, pipeWriter := io.Pipe()
|
|
// If the test gives up, this makes add's read fail, so that both
|
|
// commands return and release the lock the other tests use
|
|
t.Cleanup(func() { _ = pipeReader.Close() })
|
|
|
|
const commands = 2
|
|
|
|
input := &readNotifier{Reader: pipeReader, reading: make(chan struct{})}
|
|
results := make(chan error, commands)
|
|
|
|
go func() {
|
|
add := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
add.cmd = &cobra.Command{}
|
|
add.cmd.SetIn(input)
|
|
|
|
results <- add.AddSecret("encrypted", false)
|
|
}()
|
|
|
|
go func() {
|
|
<-input.reading
|
|
|
|
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
encrypt.cmd = &cobra.Command{}
|
|
encrypt.cmd.SetOut(pipeWriter)
|
|
|
|
err := encrypt.Encrypt("key", testInput, "")
|
|
// Ends add's input, as the end of the pipe does
|
|
_ = pipeWriter.CloseWithError(err)
|
|
|
|
results <- err
|
|
}()
|
|
|
|
timeout := time.After(lockWait)
|
|
|
|
for range commands {
|
|
select {
|
|
case err := <-results:
|
|
require.NoError(t, err)
|
|
case <-timeout:
|
|
t.Fatal("secret encrypt piped into secret add never finished")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFailedCommandReleasesLock checks that a command failing after it
|
|
// took the state directory lock leaves the lock free for the next command.
|
|
//
|
|
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
|
func TestFailedCommandReleasesLock(t *testing.T) {
|
|
fs := afero.NewMemMapFs()
|
|
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
|
|
// Fails once it holds the lock: there is no current vault. Without
|
|
// --force it would fail before taking the lock, on the check it makes
|
|
// before asking.
|
|
err := cli.RemoveSecret(&cobra.Command{}, "missing", true)
|
|
require.Error(t, err)
|
|
|
|
select {
|
|
case release := <-lockInBackground(t, fs):
|
|
release()
|
|
case <-time.After(lockWait):
|
|
t.Fatal("the failed command left the state directory locked")
|
|
}
|
|
}
|
|
|
|
// stateDirModTimes returns the modification time of every file and
|
|
// directory under the test state directory. Any change a command makes, even
|
|
// rewriting a file with the same content, changes it.
|
|
func stateDirModTimes(t *testing.T, fs afero.Fs) map[string]int64 {
|
|
t.Helper()
|
|
|
|
modTimes := make(map[string]int64)
|
|
|
|
err := afero.Walk(fs, testStateDir,
|
|
func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
modTimes[path] = info.ModTime().UnixNano()
|
|
|
|
return nil
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
return modTimes
|
|
}
|
|
|
|
// setupEveryCommand makes what the commands that the lock tests below run
|
|
// need: the current vault "work" with two versions of "test/secret", the
|
|
// vault "other", and the file testInput.
|
|
// There is no vault "default", which init creates. If withUnlocker is set,
|
|
// it also gives "work" a passphrase unlocker, which is slow. It returns the
|
|
// older version and the unlocker's ID.
|
|
func setupEveryCommand(
|
|
t *testing.T, fs afero.Fs, withUnlocker bool,
|
|
) (string, string) {
|
|
t.Helper()
|
|
|
|
mnemonic := testMnemonicBuffer(t)
|
|
|
|
_, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
|
|
require.NoError(t, err)
|
|
|
|
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic, nil)
|
|
require.NoError(t, err)
|
|
|
|
addTestSecret(t, vlt, []byte("older"), false)
|
|
addTestSecret(t, vlt, []byte("newer"), true)
|
|
|
|
vaultDir, err := vlt.GetDirectory()
|
|
require.NoError(t, err)
|
|
|
|
versions, err := secret.ListVersions(fs,
|
|
filepath.Join(vaultDir, "secrets.d", "test%secret"))
|
|
require.NoError(t, err)
|
|
require.Len(t, versions, 2)
|
|
|
|
unlockerID := ""
|
|
|
|
if withUnlocker {
|
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
defer passphrase.Destroy()
|
|
|
|
unlocker, err := vlt.CreatePassphraseUnlocker(passphrase)
|
|
require.NoError(t, err)
|
|
|
|
unlockerID = unlocker.GetID()
|
|
}
|
|
|
|
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
|
|
|
|
// Newest first
|
|
return versions[1], unlockerID
|
|
}
|
|
|
|
// waitingForLock reports whether a goroutine is stopped in
|
|
// vault.LockStateDir, waiting for the in-memory filesystem's lock. The
|
|
// stack trace of such a goroutine starts with the reason it waits,
|
|
// "[sync.Mutex.Lock]", and names LockStateDir.
|
|
func waitingForLock() bool {
|
|
stacks := make([]byte, 1<<20)
|
|
stacks = stacks[:runtime.Stack(stacks, true)]
|
|
|
|
for goroutine := range bytes.SplitSeq(stacks, []byte("\n\n")) {
|
|
if bytes.Contains(goroutine, []byte("[sync.Mutex.Lock")) &&
|
|
bytes.Contains(goroutine, []byte("vault.LockStateDir(")) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// requireWaitingForLock returns once a command waits for the state
|
|
// directory lock. It fails the test if the command finishes first, sending
|
|
// its error on done, or never waits.
|
|
func requireWaitingForLock(t *testing.T, done <-chan error) {
|
|
t.Helper()
|
|
|
|
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):
|
|
}
|
|
}
|
|
}
|
|
|
|
// requireWaitsForLock runs a command, given what setupEveryCommand made,
|
|
// while holding the state directory lock. The command must neither finish
|
|
// nor change anything before it waits for the lock, and must succeed once
|
|
// the lock is released.
|
|
func requireWaitsForLock(
|
|
t *testing.T,
|
|
withUnlocker bool,
|
|
run func(cli *Instance, olderVersion, unlockerID string) error,
|
|
) {
|
|
t.Helper()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
olderVersion, unlockerID := setupEveryCommand(t, fs, withUnlocker)
|
|
|
|
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()
|
|
|
|
// Taken only now, since taking the lock writes the lock file.
|
|
before := stateDirModTimes(t, fs)
|
|
|
|
unlockPassphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
defer unlockPassphrase.Destroy()
|
|
|
|
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
cli.Mnemonic = testMnemonicBuffer(t)
|
|
cli.UnlockPassphrase = unlockPassphrase
|
|
cli.cmd = &cobra.Command{}
|
|
cli.cmd.SetIn(strings.NewReader("value"))
|
|
cli.cmd.SetOut(io.Discard)
|
|
|
|
done := make(chan error, 1)
|
|
|
|
go func() { done <- run(cli, olderVersion, unlockerID) }()
|
|
|
|
requireWaitingForLock(t, done)
|
|
|
|
assert.Equal(t, before, stateDirModTimes(t, fs),
|
|
"changed the state directory before waiting for the lock")
|
|
|
|
release()
|
|
|
|
select {
|
|
case err := <-done:
|
|
require.NoError(t, err)
|
|
case <-time.After(lockWait):
|
|
t.Fatal("did not finish once the lock was released")
|
|
}
|
|
}
|
|
|
|
// TestChangingCommandsWaitForLock checks that each command that changes the
|
|
// state directory waits for its lock.
|
|
//
|
|
//nolint:paralleltest // waitingForLock sees any test's command waiting for the lock
|
|
func TestChangingCommandsWaitForLock(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
withUnlocker bool
|
|
run func(cli *Instance, olderVersion, unlockerID string) error
|
|
}{
|
|
{"add", false, func(cli *Instance, _, _ string) error {
|
|
return cli.AddSecret("added", false)
|
|
}},
|
|
{"import", false, func(cli *Instance, _, _ string) error {
|
|
return cli.ImportSecret(cli.cmd, "imported", testInput, false)
|
|
}},
|
|
{"generate secret", false, func(cli *Instance, _, _ string) error {
|
|
return cli.GenerateSecret(cli.cmd, "generated", 16, "base58", false)
|
|
}},
|
|
{"encrypt", false, func(cli *Instance, _, _ string) error {
|
|
return cli.Encrypt("key", testInput, "")
|
|
}},
|
|
{"rm --force", false, func(cli *Instance, _, _ string) error {
|
|
return cli.RemoveSecret(cli.cmd, "test/secret", true)
|
|
}},
|
|
{"move", false, func(cli *Instance, _, _ string) error {
|
|
return cli.MoveSecret(cli.cmd, "test/secret", "moved", false)
|
|
}},
|
|
{"version promote", false, func(cli *Instance, olderVersion, _ string) error {
|
|
return cli.PromoteVersion(cli.cmd, "test/secret", olderVersion)
|
|
}},
|
|
{"version rm --force", false, func(cli *Instance, olderVersion, _ string) error {
|
|
return cli.RemoveVersion(cli.cmd, "test/secret", olderVersion, true)
|
|
}},
|
|
{"vault create", false, func(cli *Instance, _, _ string) error {
|
|
return cli.CreateVault(cli.cmd, "created")
|
|
}},
|
|
{"vault select", false, func(cli *Instance, _, _ string) error {
|
|
return cli.SelectVault(cli.cmd, "other")
|
|
}},
|
|
{"vault rm --force", false, func(cli *Instance, _, _ string) error {
|
|
return cli.RemoveVault(cli.cmd, "other", true)
|
|
}},
|
|
{"unlocker add", false, func(cli *Instance, _, _ string) error {
|
|
return cli.UnlockersAdd("passphrase", cli.cmd)
|
|
}},
|
|
{"unlocker rm --force", true, func(cli *Instance, _, unlockerID string) error {
|
|
return cli.UnlockersRemove(unlockerID, true, cli.cmd)
|
|
}},
|
|
{"unlocker select", true, func(cli *Instance, _, unlockerID string) error {
|
|
return cli.UnlockerSelect(unlockerID)
|
|
}},
|
|
{"init", false, func(cli *Instance, _, _ string) error {
|
|
return cli.Init(cli.cmd)
|
|
}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
requireWaitsForLock(t, tc.withUnlocker, tc.run)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCommandsAskBeforeLocking is a regression test for
|
|
// https://git.eeqj.de/sneak/secret/issues/152: these commands took the
|
|
// state directory lock before asking for the mnemonic or a passphrase, so
|
|
// while one waited at its prompt, every other command that changes anything
|
|
// waited for it. Each runs here while the test holds the lock, given what
|
|
// takes it to the last step at which it can ask: the passphrase prompt of
|
|
// init and vault create, and the unlock of the vault, which asks for the
|
|
// passphrase of its passphrase unlocker, for the others. The tests have no
|
|
// terminal, so a prompt fails at once. Each command must get there, and
|
|
// fail, without waiting for the lock.
|
|
//
|
|
//nolint:paralleltest // waitingForLock sees any test's command waiting for the lock
|
|
func TestCommandsAskBeforeLocking(t *testing.T) {
|
|
newTestGPGKey(t)
|
|
|
|
mnemonic := testMnemonicBuffer(t)
|
|
|
|
wrongPassphrase := memguard.NewBufferFromBytes([]byte("wrong-passphrase"))
|
|
t.Cleanup(wrongPassphrase.Destroy)
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
run func(cli *Instance) error
|
|
// want is wrapped in the error the command fails with
|
|
want error
|
|
}{
|
|
{"init", func(cli *Instance) error {
|
|
cli.Mnemonic = mnemonic
|
|
|
|
return cli.Init(cli.cmd)
|
|
}, secret.ErrNoTerminal},
|
|
{"vault create", func(cli *Instance) error {
|
|
cli.Mnemonic = mnemonic
|
|
|
|
return cli.CreateVault(cli.cmd, "created")
|
|
}, secret.ErrNoTerminal},
|
|
// With no terminal to ask for the new passphrase on, the new
|
|
// unlocker takes the one in SB_UNLOCK_PASSPHRASE, which then fails
|
|
// to unlock the vault
|
|
{"unlocker add passphrase", func(cli *Instance) error {
|
|
cli.UnlockPassphrase = wrongPassphrase
|
|
|
|
return cli.UnlockersAdd("passphrase", cli.cmd)
|
|
}, secret.ErrWrongPassphrase},
|
|
{"unlocker add pgp", func(cli *Instance) error {
|
|
cli.cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
|
|
|
|
return cli.UnlockersAdd("pgp", cli.cmd)
|
|
}, secret.ErrNoTerminal},
|
|
{"add --force", func(cli *Instance) error {
|
|
return cli.AddSecret("test/secret", true)
|
|
}, secret.ErrNoTerminal},
|
|
{"import --force", func(cli *Instance) error {
|
|
return cli.ImportSecret(cli.cmd, "test/secret", testInput, true)
|
|
}, secret.ErrNoTerminal},
|
|
{"generate secret --force", func(cli *Instance) error {
|
|
return cli.GenerateSecret(cli.cmd, "test/secret", 16, "base58", true)
|
|
}, secret.ErrNoTerminal},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
requireAsksBeforeLocking(t, tc.run, tc.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
// startWhileLocked makes what setupEveryCommand makes, with the passphrase
|
|
// unlocker, on fs, an empty in-memory filesystem, takes the state directory
|
|
// lock there, and starts run, given a CLI instance on fs with "value" on
|
|
// stdin. It returns the function that releases the lock, which does so only
|
|
// the first time it is called, and the channel that run's error comes on.
|
|
// The lock is also released when the test ends, so that later tests can
|
|
// take it.
|
|
func startWhileLocked(
|
|
t *testing.T, fs afero.Fs, run func(cli *Instance) error,
|
|
) (func(), <-chan error) {
|
|
t.Helper()
|
|
|
|
setupEveryCommand(t, fs, true)
|
|
|
|
release, err := vault.LockStateDir(fs, testStateDir)
|
|
require.NoError(t, err)
|
|
|
|
release = sync.OnceFunc(release)
|
|
t.Cleanup(release)
|
|
|
|
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
cli.cmd = &cobra.Command{}
|
|
cli.cmd.SetIn(strings.NewReader("value"))
|
|
cli.cmd.SetOut(io.Discard)
|
|
|
|
done := make(chan error, 1)
|
|
|
|
go func() { done <- run(cli) }()
|
|
|
|
return release, done
|
|
}
|
|
|
|
// requireAsksBeforeLocking starts run while holding the state directory
|
|
// lock, as startWhileLocked does. run must fail, with an error wrapping
|
|
// want, without waiting for the lock.
|
|
func requireAsksBeforeLocking(
|
|
t *testing.T, run func(cli *Instance) error, want error,
|
|
) {
|
|
t.Helper()
|
|
|
|
release, done := startWhileLocked(t, afero.NewMemMapFs(), run)
|
|
|
|
timeout := time.After(lockWait)
|
|
|
|
for !waitingForLock() {
|
|
select {
|
|
case err := <-done:
|
|
require.ErrorIs(t, err, want)
|
|
|
|
return
|
|
case <-timeout:
|
|
t.Fatal("neither failed nor waited for the lock")
|
|
case <-time.After(time.Millisecond):
|
|
}
|
|
}
|
|
|
|
// Let the command go on, so that it releases the lock
|
|
release()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(lockWait):
|
|
}
|
|
|
|
t.Fatal("waited for the lock before asking")
|
|
}
|
|
|
|
// TestCommandsCheckAgainOnceLocked checks that commands which check what
|
|
// they change before they ask for anything, and so before they take the
|
|
// state directory lock, check again once they hold it: another command may
|
|
// have changed it in the meantime. Each runs while the test holds the lock,
|
|
// given what it needs to ask for nothing. Once it waits for the lock, the
|
|
// test makes the change another command could have made, then releases the
|
|
// lock. The command must fail, and change nothing.
|
|
//
|
|
//nolint:paralleltest // waitingForLock sees any test's command waiting for the lock
|
|
func TestCommandsCheckAgainOnceLocked(t *testing.T) {
|
|
fingerprint := newTestGPGKey(t)
|
|
|
|
mnemonic := testMnemonicBuffer(t)
|
|
|
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
t.Cleanup(passphrase.Destroy)
|
|
|
|
// work returns the vault "work" on fs, opened with the mnemonic
|
|
work := func(fs afero.Fs) *vault.Vault {
|
|
vlt := vault.NewVault(fs, testStateDir, "work")
|
|
vlt.Mnemonic = mnemonic
|
|
|
|
return vlt
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
run func(cli *Instance) error
|
|
// change is what another command changes while run waits
|
|
change func(fs afero.Fs) error
|
|
// want is wrapped in the error the command fails with
|
|
want error
|
|
}{
|
|
{
|
|
"unlocker add, vault removed",
|
|
func(cli *Instance) error {
|
|
cli.Mnemonic, cli.UnlockPassphrase = mnemonic, passphrase
|
|
|
|
return cli.UnlockersAdd("passphrase", cli.cmd)
|
|
},
|
|
func(fs afero.Fs) error {
|
|
return fs.RemoveAll(testStateDir + "/vaults.d/work")
|
|
},
|
|
vault.ErrVaultNotFound,
|
|
},
|
|
{
|
|
"unlocker add pgp, key added",
|
|
func(cli *Instance) error {
|
|
cli.Mnemonic = mnemonic
|
|
cli.cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
|
|
|
|
return cli.UnlockersAdd("pgp", cli.cmd)
|
|
},
|
|
func(fs afero.Fs) error {
|
|
_, err := secret.CreatePGPUnlocker(work(fs),
|
|
unreadableTestGPGUserID, fingerprint)
|
|
|
|
return err
|
|
},
|
|
errGPGKeyAlreadyUnlocker,
|
|
},
|
|
{
|
|
// Without the mnemonic or a passphrase, replacing the secret
|
|
// would ask for the passphrase; it does not exist yet, so the
|
|
// add asks for nothing
|
|
"add --force, secret added",
|
|
func(cli *Instance) error {
|
|
return cli.AddSecret("added", true)
|
|
},
|
|
func(fs afero.Fs) error {
|
|
value := memguard.NewBufferFromBytes([]byte("other"))
|
|
defer value.Destroy()
|
|
|
|
return work(fs).AddSecret("added", value, false)
|
|
},
|
|
errAddedMeanwhile,
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
requireChecksAgainOnceLocked(t, tc.run, tc.change, tc.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
// requireChecksAgainOnceLocked starts run while holding the state directory
|
|
// lock, as startWhileLocked does. Once run waits for the lock, change makes
|
|
// the change that another command could have made in the meantime, and the
|
|
// lock is released. run must then fail, with an error wrapping want, and
|
|
// change nothing.
|
|
func requireChecksAgainOnceLocked(
|
|
t *testing.T, run func(cli *Instance) error,
|
|
change func(fs afero.Fs) error, want error,
|
|
) {
|
|
t.Helper()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
release, done := startWhileLocked(t, fs, run)
|
|
|
|
requireWaitingForLock(t, done)
|
|
require.NoError(t, change(fs))
|
|
|
|
// Left out: taking and releasing the lock writes the lock file
|
|
lockFile := testStateDir + "/lock"
|
|
|
|
before := stateDirModTimes(t, fs)
|
|
delete(before, lockFile)
|
|
|
|
release()
|
|
|
|
select {
|
|
case err := <-done:
|
|
require.ErrorIs(t, err, want)
|
|
case <-time.After(lockWait):
|
|
t.Fatal("did not finish once the lock was released")
|
|
}
|
|
|
|
after := stateDirModTimes(t, fs)
|
|
delete(after, lockFile)
|
|
|
|
assert.Equal(t, before, after, "changed the state directory")
|
|
}
|
|
|
|
// TestEncryptWithExistingKeyTakesNoLock checks that secret encrypt with a
|
|
// key that already exists, which only reads the state directory, finishes
|
|
// while another command holds the state directory lock.
|
|
//
|
|
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
|
func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
|
|
mnemonic := testMnemonicBuffer(t)
|
|
|
|
fs := afero.NewMemMapFs()
|
|
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil)
|
|
require.NoError(t, err)
|
|
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
|
|
|
|
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
encrypt.Mnemonic = mnemonic
|
|
encrypt.cmd = &cobra.Command{}
|
|
encrypt.cmd.SetOut(io.Discard)
|
|
|
|
// Stores the key
|
|
require.NoError(t, encrypt.Encrypt("key", testInput, ""))
|
|
|
|
release, err := vault.LockStateDir(fs, testStateDir)
|
|
require.NoError(t, err)
|
|
// Also frees a waiting encrypt if the test fails, so that it releases
|
|
// the lock the other tests use
|
|
defer release()
|
|
|
|
done := make(chan error, 1)
|
|
|
|
go func() { done <- encrypt.Encrypt("key", testInput, "") }()
|
|
|
|
select {
|
|
case err := <-done:
|
|
require.NoError(t, err)
|
|
case <-time.After(lockWait):
|
|
t.Fatal("secret encrypt with an existing key waited for the lock")
|
|
}
|
|
}
|
|
|
|
// TestEncryptStreamsUnlocked checks that secret encrypt has released the
|
|
// state directory lock by the time it writes its output. Holding it while
|
|
// streaming would stall every other changing command for as long as the
|
|
// stream lasts, and forever when the other end of the pipe is one of them.
|
|
//
|
|
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
|
func TestEncryptStreamsUnlocked(t *testing.T) {
|
|
fs := afero.NewMemMapFs()
|
|
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t), nil)
|
|
require.NoError(t, err)
|
|
require.NoError(t, afero.WriteFile(fs, testInput, []byte("streamed"), 0o600))
|
|
|
|
outputReader, outputWriter := io.Pipe()
|
|
done := make(chan error, 1)
|
|
|
|
go func() {
|
|
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
encrypt.cmd = &cobra.Command{}
|
|
encrypt.cmd.SetOut(outputWriter)
|
|
|
|
err := encrypt.Encrypt("key", testInput, "")
|
|
_ = outputWriter.CloseWithError(err)
|
|
|
|
done <- err
|
|
}()
|
|
|
|
// The first byte of output: encrypt is streaming now, and blocked
|
|
// writing until it is read
|
|
_, err = io.ReadFull(outputReader, make([]byte, 1))
|
|
require.NoError(t, err)
|
|
|
|
taken := lockInBackground(t, fs)
|
|
|
|
select {
|
|
case release := <-taken:
|
|
release()
|
|
case <-time.After(lockWait):
|
|
// Let encrypt finish, so that it releases the lock, then free it
|
|
// again for the tests that follow
|
|
_, _ = io.Copy(io.Discard, outputReader)
|
|
|
|
(<-taken)()
|
|
t.Fatal("secret encrypt held the lock while streaming")
|
|
}
|
|
|
|
_, err = io.Copy(io.Discard, outputReader)
|
|
require.NoError(t, err)
|
|
require.NoError(t, <-done)
|
|
}
|