check / check (push) Waiting to run
init and vault create put the mnemonic into the process environment for vault.CreateVault to read back, so every program they ran, gpg included, inherited it, and SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE were read at 13 places and never unset. Each command that may need them now reads both once, in its RunE, into locked buffers on the CLI Instance, and unsets them at once. The buffers are passed down: vault.CreateVault takes the mnemonic, a Vault carries Mnemonic and UnlockPassphrase, and the PGP, keychain and Secure Enclave unlocker constructors take both. Nothing below the command reads the environment. README warns against both variables. Model: opus-5-5
565 lines
16 KiB
Go
565 lines
16 KiB
Go
//nolint:testpackage // sets the unexported fields of Instance
|
|
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"git.eeqj.de/sneak/secret/internal/vault"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
"github.com/spf13/cobra"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
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.
|
|
//
|
|
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
|
func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
|
|
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) {
|
|
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic)
|
|
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))
|
|
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.
|
|
func TestFailedCommandReleasesLock(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
|
|
// Fails once it holds the lock: there is no current vault
|
|
err := cli.RemoveSecret(&cobra.Command{}, "missing", false)
|
|
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 each command in
|
|
// TestChangingCommandsWaitForLock needs: the current vault "work" with two
|
|
// versions of "test/secret", the vault "other" without a long-term key, for
|
|
// vault import, 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)
|
|
|
|
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic)
|
|
require.NoError(t, err)
|
|
|
|
otherDir, err := other.GetDirectory()
|
|
require.NoError(t, err)
|
|
require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age")))
|
|
|
|
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic)
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
before := stateDirModTimes(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()
|
|
|
|
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) }()
|
|
|
|
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):
|
|
}
|
|
}
|
|
|
|
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", false, func(cli *Instance, _, _ string) error {
|
|
return cli.RemoveSecret(cli.cmd, "test/secret", false)
|
|
}},
|
|
{"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", false, func(cli *Instance, olderVersion, _ string) error {
|
|
return cli.RemoveVersion(cli.cmd, "test/secret", olderVersion)
|
|
}},
|
|
{"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 import", false, func(cli *Instance, _, _ string) error {
|
|
return cli.VaultImport(cli.cmd, "other")
|
|
}},
|
|
{"vault rm", false, func(cli *Instance, _, _ string) error {
|
|
return cli.RemoveVault(cli.cmd, "other", false)
|
|
}},
|
|
{"unlocker add", false, func(cli *Instance, _, _ string) error {
|
|
return cli.UnlockersAdd("passphrase", cli.cmd)
|
|
}},
|
|
{"unlocker rm", 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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
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))
|
|
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)
|
|
}
|