//nolint:testpackage // sets the unexported fields of Instance package cli import ( "io" "os" "path/filepath" "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 // heldWait is how long a test watches a command that must wait for the // lock. A command that takes no lock changes the state directory well // within it. heldWait = 100 * time.Millisecond // 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. func addAtOnce( fs afero.Fs, stateDir, name string, force bool, values []string, ) []error { errs := make(chan error, len(values)) for _, value := range values { go func() { cli := NewCLIInstanceWithStateDir(fs, stateDir) 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 // t.Setenv forbids parallel subtests func TestConcurrentAddsKeepEveryVersion(t *testing.T) { t.Setenv(secret.EnvMnemonic, testMnemonic) 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") 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)) { 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)) { require.NoError(t, err) } vlt, err := vault.GetCurrentVault(tc.fs, tc.stateDir) require.NoError(t, err) 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)] = true } 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. func TestEncryptPipedIntoAdd(t *testing.T) { t.Setenv(secret.EnvMnemonic, testMnemonic) fs := afero.NewMemMapFs() _, err := vault.CreateVault(fs, testStateDir, "default") 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 "default" with // two versions of "test/secret", the vault "other" without a long-term key, // for vault import, and the file testInput. If withUnlocker is set, it also // gives "default" 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() other, err := vault.CreateVault(fs, testStateDir, "other") 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, "default") 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 } // requireWaitsForLock runs a command, given what setupEveryCommand made, // while holding the state directory lock. The command must neither finish // nor change anything while the lock is held, and must succeed once it 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() 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, olderVersion, unlockerID) }() select { case err := <-done: t.Fatalf("finished while the lock was held, with error %v", err) case <-time.After(heldWait): } assert.Equal(t, before, stateDirModTimes(t, fs), "changed the state directory while the lock was held") 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 // t.Setenv forbids parallel subtests func TestChangingCommandsWaitForLock(t *testing.T) { t.Setenv(secret.EnvMnemonic, testMnemonic) t.Setenv(secret.EnvUnlockPassphrase, testPassphrase) 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) }) } } // 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 // t.Setenv forbids t.Parallel func TestEncryptStreamsUnlocked(t *testing.T) { t.Setenv(secret.EnvMnemonic, testMnemonic) fs := afero.NewMemMapFs() _, err := vault.CreateVault(fs, testStateDir, "default") 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) }