Lock the state directory and write vault files atomically (closes #34)
check / check (push) Successful in 50s
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
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
//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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user