Create a vault whole in a temporary directory, then select it (closes #105)
check / check (push) Failing after 2s

vault.CreateVault takes the unlocker passphrase and writes the vault
directory, its metadata, long-term public key and passphrase unlocker
into a temporary directory, renames that into vaults.d once complete,
and only then makes the vault current. secret init and secret vault
create call it once instead of adding the unlocker afterwards, so a
kill part-way leaves either no vault, whose temporary directory the
next command that takes the lock deletes, or a complete one. A test
records the state directory before every change the call makes and
checks each state, and the command run again from it.

Model: opus-5-5
This commit was merged in pull request #108.
This commit is contained in:
2026-10-04 20:42:03 +02:00
parent f2f89c8a06
commit 23dcea83f9
25 changed files with 421 additions and 204 deletions
+194 -1
View File
@@ -2,7 +2,11 @@ package cli_test
import (
"bytes"
"io"
"maps"
"os"
"slices"
"strings"
"testing"
"git.eeqj.de/sneak/secret/internal/cli"
@@ -155,7 +159,7 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms))
withDefault := afero.NewMemMapFs()
_, err := vault.CreateVault(withDefault, testStateDir, "default", mnemonic)
_, err := vault.CreateVault(withDefault, testStateDir, "default", mnemonic, nil)
require.NoError(t, err)
cmd := &cobra.Command{}
@@ -193,3 +197,192 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
})
}
}
// TestStopDuringCreateLeavesWholeVaultOrNone is a regression test for
// https://git.eeqj.de/sneak/secret/issues/105: `secret init` or `secret vault
// create` killed after the passphrase prompt but before the unlocker was
// written left a vault with no unlocker, which neither command would then
// create again. After the prompt, each command changes the state directory
// only through vault.CreateVault. The test makes that call as the command
// does and records the state directory before each change it makes, and once
// after it returns: what a stop at that point leaves. Each must hold either
// no vault, and not name it current, or exactly the finished vault, which
// opens with the passphrase through its current unlocker. The command run
// again after a stop first takes the lock, which must delete what the stop
// left under a temporary name. Running the command is slow, so it runs once
// on each different state the lock leaves, and must create the vault there,
// or refuse the one there.
//
//nolint:paralleltest // commands on the in-memory filesystem share one lock
func TestStopDuringCreateLeavesWholeVaultOrNone(t *testing.T) {
mnemonic := testMnemonicBuffer(t)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
cmd := &cobra.Command{}
cmd.SetOut(io.Discard)
t.Run("init", func(t *testing.T) {
// From an empty state directory
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll(testStateDir, secret.DirPerms))
requireStopsLeaveWholeVaultOrNone(t, fs, "default",
"failed to create default vault: vault default already exists",
mnemonic, passphrase,
func(c *cli.Instance) error { return c.Init(cmd) })
})
t.Run("vault create work", func(t *testing.T) {
// From a state directory holding the vault "default"
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil)
require.NoError(t, err)
requireStopsLeaveWholeVaultOrNone(t, fs, "work", "vault work already exists",
mnemonic, passphrase,
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") })
})
}
// requireStopsLeaveWholeVaultOrNone checks, as
// TestStopDuringCreateLeavesWholeVaultOrNone describes, the stops of the
// command run, creating the vault name on fs with mnemonic and passphrase.
// Run again where the vault is there, the command must fail with exists.
func requireStopsLeaveWholeVaultOrNone(
t *testing.T, fs afero.Fs, name, exists string,
mnemonic, passphrase *memguard.LockedBuffer,
run func(c *cli.Instance) error,
) {
t.Helper()
var stops []map[string]string
record := func() { stops = append(stops, snapshotStateDir(t, fs)) }
_, err := vault.CreateVault(hookFs{Fs: fs, before: record},
testStateDir, name, mnemonic, passphrase)
require.NoError(t, err)
record()
vaultDir := testStateDir + "/vaults.d/" + name
require.NotContains(t, stops[0], vaultDir+"/", "no stop before the vault")
finished := entriesUnder(stops[len(stops)-1], vaultDir)
opener := vault.NewVault(fs, testStateDir, name)
opener.UnlockPassphrase = passphrase
key, err := opener.UnlockVault()
require.NoError(t, err)
require.Equal(t, finished[vaultDir+"/pub.age"], key.Recipient().String())
// Each different state the command run again finds once it holds the lock
var locked []map[string]string
for i, stop := range stops {
if _, there := stop[vaultDir+"/"]; there {
require.Equal(t, finished, entriesUnder(stop, vaultDir),
"stop %d left a partial vault", i)
} else {
require.NotEqual(t, name, stop[testStateDir+"/currentvault"],
"stop %d made a missing vault current", i)
}
stopped := newFsFromSnapshot(t, stop)
release, err := vault.LockStateDir(stopped, testStateDir)
require.NoError(t, err)
release()
state := snapshotStateDir(t, stopped)
for path := range state {
require.NotContains(t, path, ".tmp-", "stop %d", i)
}
if !slices.ContainsFunc(locked, func(s map[string]string) bool {
return maps.Equal(s, state)
}) {
locked = append(locked, state)
}
}
for _, state := range locked {
c := cli.NewCLIInstanceWithStateDir(newFsFromSnapshot(t, state), testStateDir)
c.Mnemonic = mnemonic
c.UnlockPassphrase = passphrase
if _, there := state[vaultDir+"/"]; there {
require.EqualError(t, run(c), exists)
} else {
require.NoError(t, run(c))
}
}
}
// entriesUnder returns the entries of a tree recorded by snapshotStateDir
// that are under dir.
func entriesUnder(tree map[string]string, dir string) map[string]string {
entries := map[string]string{}
for path, content := range tree {
if strings.HasPrefix(path, dir+"/") {
entries[path] = content
}
}
return entries
}
// hookFs passes every call through to Fs, but first calls before for each
// call that can change the filesystem.
type hookFs struct {
afero.Fs
before func()
}
//nolint:ireturn // implements afero.Fs
func (h hookFs) Create(name string) (afero.File, error) {
h.before()
return h.Fs.Create(name)
}
//nolint:ireturn // implements afero.Fs
func (h hookFs) OpenFile(
name string, flag int, perm os.FileMode,
) (afero.File, error) {
h.before()
return h.Fs.OpenFile(name, flag, perm)
}
func (h hookFs) Mkdir(name string, perm os.FileMode) error {
h.before()
return h.Fs.Mkdir(name, perm)
}
func (h hookFs) MkdirAll(path string, perm os.FileMode) error {
h.before()
return h.Fs.MkdirAll(path, perm)
}
func (h hookFs) Remove(name string) error {
h.before()
return h.Fs.Remove(name)
}
func (h hookFs) RemoveAll(path string) error {
h.before()
return h.Fs.RemoveAll(path)
}
func (h hookFs) Rename(oldname, newname string) error {
h.before()
return h.Fs.Rename(oldname, newname)
}