//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) otherMnemonic := memguard.NewBufferFromBytes([]byte("legal winner thank " + "year wave sausage worth useful legal winner thank yellow")) t.Cleanup(otherMnemonic.Destroy) 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, vault created again from another mnemonic", func(cli *Instance) error { cli.Mnemonic, cli.UnlockPassphrase = mnemonic, passphrase return cli.UnlockersAdd("passphrase", cli.cmd) }, func(fs afero.Fs) error { err := fs.RemoveAll(testStateDir + "/vaults.d/work") if err != nil { return err } _, err = vault.CreateVault(fs, testStateDir, "work", otherMnemonic, passphrase) return err }, errVaultCreatedAgain, }, { "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, }, } { 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") } // TestForcedAddReplacesSecretAddedMeanwhile runs a forced add of a secret // that does not exist yet while the test holds the state directory lock. // Once it waits for the lock, the test adds the secret, as another command // could have, and releases the lock. The forced add must replace that // secret, as if it had run after the other command. // //nolint:paralleltest // waitingForLock sees any test's command waiting for the lock func TestForcedAddReplacesSecretAddedMeanwhile(t *testing.T) { mnemonic := testMnemonicBuffer(t) fs := afero.NewMemMapFs() release, done := startWhileLocked(t, fs, func(cli *Instance) error { cli.Mnemonic = mnemonic return cli.AddSecret("added", true) }) requireWaitingForLock(t, done) work := vault.NewVault(fs, testStateDir, "work") work.Mnemonic = mnemonic other := memguard.NewBufferFromBytes([]byte("other")) t.Cleanup(other.Destroy) require.NoError(t, work.AddSecret("added", other, false)) release() select { case err := <-done: require.NoError(t, err) case <-time.After(lockWait): t.Fatal("did not finish once the lock was released") } value, err := work.GetSecret("added") require.NoError(t, err) t.Cleanup(value.Destroy) assert.Equal(t, "value", string(value.Bytes())) } // 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) }