//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) }