check / check (push) Successful in 50s
Each command that changes the state directory holds one lock: flock(2) on `lock` in the state directory, dropped by the kernel if the process dies, or a process-wide mutex on the in-memory test filesystem. It covers the state directory, not each vault, because `currentvault`, `vault create` and cross-vault moves span vaults, and a lock file in a vault would be deleted by `vault remove` under a waiting command. Files go through `secret.WriteFileAtomic`; versions, new secrets and cross-vault copies are built in a temporary directory and renamed into place; removals rename out of the way first. An unlocker added under an existing unlocker's directory name is still rewritten file by file: #71. Model: opus-5-5
225 lines
5.8 KiB
Go
225 lines
5.8 KiB
Go
//nolint:testpackage // sets the unexported fields of Instance
|
|
package cli
|
|
|
|
import (
|
|
"io"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"git.eeqj.de/sneak/secret/internal/vault"
|
|
"github.com/spf13/afero"
|
|
"github.com/spf13/cobra"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// 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, "/plaintext", []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", "/plaintext", "")
|
|
// Ends add's input, as the end of the pipe does
|
|
_ = pipeWriter.CloseWithError(err)
|
|
|
|
results <- err
|
|
}()
|
|
|
|
timeout := time.After(10 * time.Second)
|
|
|
|
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)
|
|
|
|
taken := make(chan func(), 1)
|
|
|
|
go func() {
|
|
release, err := vault.LockStateDir(fs, testStateDir)
|
|
if assert.NoError(t, err) {
|
|
taken <- release
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case release := <-taken:
|
|
release()
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("the failed command left the state directory locked")
|
|
}
|
|
}
|