check / check (push) Failing after 3s
secret rm, secret version rm, secret vault remove and secret unlocker remove ask [y/N] on a terminal, naming what they remove, and go ahead only on y or yes. Without --force, a command whose stdin is not a terminal fails at once. --force, now also on rm and version rm, removes without asking; it replaces the old refusals to remove a vault with secrets or the last unlocker without --force. The checks run and the question is asked before the state directory lock is taken; under the lock the checks run again, and nothing is removed if they would ask a different question. Model: opus-5-5
567 lines
16 KiB
Go
567 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. 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 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 --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 import", false, func(cli *Instance, _, _ string) error {
|
|
return cli.VaultImport(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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|