2 Commits
Author SHA1 Message Date
clawbot 4d030f1109 Make another unlocker current when the current one is removed (closes #137)
check / check (push) Canceled after 0s
Removing the vault's current unlocker left current-unlocker naming it,
so every command that opened the vault failed although another
unlocker remained, and the error pointed at the mnemonic.
RemoveUnlocker now first makes current the first other unlocker that
`secret unlocker select` accepts, in the order `secret unlocker list`
shows, and returns its ID, which `secret unlocker remove` prints; with
none, current-unlocker is removed too. When current-unlocker names an
unlocker that does not exist and the vault has another that select
accepts, the error wraps ErrUnlockerNotFound and says to run
`secret unlocker select`, without the mnemonic advice.

Unverified: no test covers that advice for a vault other than the
current one, which gets `secret vault select` first.

Model: opus-5-5
2026-10-14 19:04:26 +00:00
clawbot 5371382cb4 A failed secret add --force leaves the secret unchanged (closes #136)
check / check (push) Canceled after 0s
addVersion wrote the new version before it unlocked the vault to set
notAfter on the version being replaced, so a wrong passphrase or mnemonic
left an extra version behind. It now unlocks the vault and loads that
version's metadata first; writing notAfter and switching current still
come after the new version is saved. generate secret --force and
import --force take the same path.

The time set as the new version's notBefore and the replaced version's
notAfter is taken after the unlock, which can wait for the passphrase.

updatePreviousVersion asks for the long-term key again. The vault keeps
it in memory after the first unlock, so this neither prompts nor derives
it a second time.

Model: opus-5-5
2026-10-14 20:48:48 +02:00
9 changed files with 345 additions and 82 deletions
+8 -6
View File
@@ -241,12 +241,14 @@ that the vault then opens only with its mnemonic (see
[Confirmation Before Removal](#confirmation-before-removal)). An unlocker
directory that `secret unlocker list` skips with a warning, because its metadata
cannot be read or parsed, is removed by the directory name the warning gives.
Removing the current unlocker makes the first of the vault's other unlockers, in
the order `secret unlocker list` shows them, the current one, and says which;
removing the last unlocker leaves the vault without a current unlocker. When
`current-unlocker` names an unlocker that does not exist and the vault has
others, opening the vault fails with an error that says to select one of them
with `secret unlocker select`.
Removing the current unlocker makes the first of the vault's other unlockers
that `secret unlocker select` accepts, in the order `secret unlocker list` shows
them, the current one, and says which; when there is none, the vault is left
without a current unlocker. `secret unlocker select` refuses an unlocker whose
type is not `passphrase` or `pgp`. When `current-unlocker` names an unlocker
that does not exist and the vault has another that `secret unlocker select`
accepts, opening the vault fails with an error that says to select one with
`secret unlocker select`.
- `--force, -f`: Remove without asking, even the last unlocker
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
+16 -7
View File
@@ -23,13 +23,22 @@ https://git.eeqj.de/sneak/secret/milestone/12
(https://git.eeqj.de/sneak/secret/issues/137). `current-unlocker` went on
naming the removed unlocker, so every command that opened the vault failed,
with advice to use the mnemonic. `secret unlocker remove` of the current
unlocker now makes the first of the vault's other unlockers, in the order
`secret unlocker list` shows, current before removing it, and says so;
removing the last unlocker also removes `current-unlocker`.
`vault.RemoveUnlocker` returns the ID of the unlocker it made current. When
`current-unlocker` names an unlocker that does not exist and the vault has
others, the error wraps `vault.ErrUnlockerNotFound` and says to run
`secret unlocker select`, without the advice to use the mnemonic.
unlocker now makes the first of the vault's other unlockers that
`secret unlocker select` accepts, in the order `secret unlocker list` shows,
current before removing it, and says so; when there is none, it also removes
`current-unlocker`. `vault.RemoveUnlocker` returns the ID of the unlocker it
made current. When `current-unlocker` names an unlocker that does not exist
and the vault has another that `secret unlocker select` accepts, the error
wraps `vault.ErrUnlockerNotFound` and says to run `secret unlocker select`,
without the advice to use the mnemonic.
- 2026-10-14: A `secret add --force` that cannot unlock the vault, given a wrong
passphrase or mnemonic, leaves the secret as it was
(https://git.eeqj.de/sneak/secret/issues/136). The new version used to be
written before the vault was unlocked to set `notAfter` on the version it
replaces, so each failed add left one more version that `secret version list`
showed. The vault is now unlocked, and that version's metadata read, before
anything is written. `generate secret --force` and `import --force` take the
same path.
- 2026-10-12: A mnemonic typed with extra spaces, tabs or line breaks gives the
same long-term key as its words separated by single spaces
(https://git.eeqj.de/sneak/secret/issues/135). The key was derived from the
+37 -1
View File
@@ -5,7 +5,8 @@
// keeps its cause and ends by saying that the mnemonic still opens that
// vault, but only for a vault that the mnemonic does open, and not when the
// passphrase could not be read at all. When the current unlocker does not
// exist and the vault has another, the error says to select one instead.
// exist and the vault has another that `secret unlocker select` accepts, the
// error says to select one instead.
// When a secret's current file is missing, the error says how to make a
// version current again. Each test that pins such advice also follows it.
@@ -251,6 +252,41 @@ func TestMissingCurrentUnlockerNamesSelect(t *testing.T) {
assert.Equal(t, "value", getSecret(t, c, "x"))
}
// TestMissingCurrentUnlockerNamesMnemonic checks the error of `secret get`
// when the unlocker that current-unlocker names does not exist and the
// vault's only other unlocker is the directory "odd", whose metadata names a
// type that `secret unlocker select` refuses: the error ends with the advice
// that the mnemonic still opens the vault, not with advice to select another
// unlocker. The test then follows that advice.
func TestMissingCurrentUnlockerNamesMnemonic(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
unlockerDir, c := newUnlockTestCLI(t, fs)
require.NoError(t, fs.RemoveAll(unlockerDir))
require.NoError(t, afero.WriteFile(fs,
filepath.Join(unlockTestVaultDir, "unlockers.d", "odd",
"unlocker-metadata.json"),
[]byte(`{"type": "odd"}`), secret.FilePerms))
err := c.GetSecret(discardCmd(), "x")
var cause *os.PathError
require.ErrorAs(t, err, &cause)
require.NotErrorIs(t, err, vault.ErrUnlockerNotFound)
require.EqualError(t, err, "failed to unlock vault: "+
"failed to get long-term key: failed to get current unlocker: "+
"failed to read unlocker metadata: "+cause.Error()+mnemonicAdvice)
c.Mnemonic = testMnemonicBuffer(t)
require.NoError(t, c.UnlockersAdd("passphrase", discardCmd()))
c.Mnemonic = nil
assert.Equal(t, "value", getSecret(t, c, "x"))
}
// TestPassphraseNotReadNamesNoMnemonic runs `secret get x` on the built
// binary without SB_UNLOCK_PASSPHRASE and with a stdin that is not a
// terminal, so the passphrase cannot be read. The unlocker was not tried,
+2 -1
View File
@@ -223,7 +223,8 @@ func newUnlockerRemoveCmd() *cobra.Command {
`confirmation first, saying whether it is the vault's last ` +
`unlocker; when stdin is not a terminal, fails unless --force ` +
`is given. Removing the current unlocker makes another of the ` +
`vault's unlockers current, if it has one. Warning: Without ` +
`vault's unlockers current, if it has one that ` +
`'secret unlocker select' accepts. Warning: Without ` +
`unlockers and without your mnemonic, vault data will be ` +
`permanently inaccessible.`,
Args: cobra.ExactArgs(1),
+59 -33
View File
@@ -10,6 +10,7 @@ import (
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
)
@@ -17,52 +18,77 @@ import (
// https://git.eeqj.de/sneak/secret/issues/137. A vault has a passphrase
// unlocker and a PGP unlocker, the current one. Removing the PGP unlocker
// makes the passphrase unlocker current and says so, and the vault then
// opens through it. Removing the passphrase unlocker too, the vault's last,
// removes current-unlocker.
// opens through it. Removing the passphrase unlocker too removes
// current-unlocker.
//
// The second case also gives the vault the unlocker directory "odd", whose
// metadata names a type that `secret unlocker select` refuses, and which
// sorts before the passphrase unlocker: neither removal makes it current.
//
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
func TestUnlockerRemoveCurrent(t *testing.T) {
newTestGPGKey(t)
tests := []struct {
name string
withOdd bool
}{
{name: "two unlockers", withOdd: false},
{name: "and the directory odd", withOdd: true},
}
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
newTestGPGKey(t)
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
testMnemonicBuffer(t), nil)
require.NoError(t, err)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, listTestStateDir,
listTestVaultName, testMnemonicBuffer(t), nil)
require.NoError(t, err)
instance, cmd := newTestInstance(fs)
instance.UnlockPassphrase = passphrase
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd))
if tt.withOdd {
require.NoError(t, afero.WriteFile(fs,
filepath.Join(testVaultDir(listTestVaultName), "unlockers.d",
"odd", "unlocker-metadata.json"),
[]byte(`{"type": "odd"}`), secret.FilePerms))
}
pgpUnlocker, err := vlt.GetCurrentUnlocker()
require.NoError(t, err)
require.Equal(t, unlockerTypePGP, pgpUnlocker.GetType())
instance, cmd := newTestInstance(fs)
instance.UnlockPassphrase = passphrase
var output bytes.Buffer
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd))
cmd.SetOut(&output)
require.NoError(t, instance.UnlockersRemove(pgpUnlocker.GetID(), true, cmd))
assert.Contains(t, output.String(),
"Switched current unlocker to '"+passphraseUnlocker.GetID()+"'")
pgpUnlocker, err := vlt.GetCurrentUnlocker()
require.NoError(t, err)
require.Equal(t, unlockerTypePGP, pgpUnlocker.GetType())
reopened := vault.NewVault(fs, listTestStateDir, listTestVaultName)
reopened.UnlockPassphrase = passphrase
var output bytes.Buffer
_, err = reopened.GetOrDeriveLongTermKey()
require.NoError(t, err)
cmd.SetOut(&output)
require.NoError(t,
instance.UnlockersRemove(pgpUnlocker.GetID(), true, cmd))
assert.Contains(t, output.String(),
"Switched current unlocker to '"+passphraseUnlocker.GetID()+"'")
require.NoError(t,
instance.UnlockersRemove(passphraseUnlocker.GetID(), true, cmd))
reopened := vault.NewVault(fs, listTestStateDir, listTestVaultName)
reopened.UnlockPassphrase = passphrase
exists, err := afero.Exists(fs,
filepath.Join(testVaultDir(listTestVaultName), "current-unlocker"))
require.NoError(t, err)
assert.False(t, exists, "current-unlocker is left after the last unlocker")
_, err = reopened.GetOrDeriveLongTermKey()
require.NoError(t, err)
require.NoError(t,
instance.UnlockersRemove(passphraseUnlocker.GetID(), true, cmd))
exists, err := afero.Exists(fs, filepath.Join(
testVaultDir(listTestVaultName), "current-unlocker"))
require.NoError(t, err)
assert.False(t, exists,
"current-unlocker is left after the passphrase unlocker")
})
}
}
+39 -12
View File
@@ -219,6 +219,15 @@ func (v *Vault) addVersion(
name, secretDir string, value *memguard.LockedBuffer,
previousVersion *secret.Version,
) error {
// Unlock the vault before writing anything, so that a wrong passphrase
// or mnemonic leaves the secret as it was
err := v.loadPreviousVersionMetadata(previousVersion)
if err != nil {
return err
}
// Taken after the unlock, which can wait for the user to type the
// passphrase, so that the new version starts when it is created
now := time.Now()
// Create the new version and save the encrypted value
@@ -632,8 +641,35 @@ func (v *Vault) checkExistingSecret(
return true, previousVersion, nil
}
// loadPreviousVersionMetadata unlocks the vault and loads the metadata of
// the version being superseded. It is a no-op when previousVersion is nil.
func (v *Vault) loadPreviousVersionMetadata(
previousVersion *secret.Version,
) error {
if previousVersion == nil {
return nil
}
ltIdentity, err := v.GetOrDeriveLongTermKey()
if err != nil {
secret.Debug("Failed to get long-term key for previous version", "error", err)
return fmt.Errorf("failed to get long-term key: %w", err)
}
err = previousVersion.LoadMetadata(ltIdentity)
if err != nil {
secret.Debug("Failed to load previous version metadata", "error", err)
return fmt.Errorf("failed to load previous version metadata: %w", err)
}
return nil
}
// updatePreviousVersion sets the notAfter timestamp on the version being
// superseded. It is a no-op when previousVersion is nil.
// superseded, whose metadata loadPreviousVersionMetadata has loaded. It is
// a no-op when previousVersion is nil.
func (v *Vault) updatePreviousVersion(
previousVersion *secret.Version, now *time.Time,
) error {
@@ -641,22 +677,13 @@ func (v *Vault) updatePreviousVersion(
return nil
}
// Get long-term key to decrypt/encrypt metadata
// loadPreviousVersionMetadata unlocked the vault, so this returns the
// key it keeps in memory
ltIdentity, err := v.GetOrDeriveLongTermKey()
if err != nil {
secret.Debug("Failed to get long-term key for metadata update", "error", err)
return fmt.Errorf("failed to get long-term key: %w", err)
}
// Load previous version metadata
err = previousVersion.LoadMetadata(ltIdentity)
if err != nil {
secret.Debug("Failed to load previous version metadata", "error", err)
return fmt.Errorf("failed to load previous version metadata: %w", err)
}
// Update notAfter timestamp
previousVersion.Metadata.NotAfter = now
+66
View File
@@ -10,6 +10,8 @@
// version
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter)
// across versions
// - TestVaultVersionTimestampsAfterUnlock: Tests that those timestamps are
// taken after AddSecret unlocks the vault
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
// - TestUpdateVersionMetadata: Tests metadata update functionality
//
@@ -295,6 +297,70 @@ func TestVaultVersionTimestamps(t *testing.T) {
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
}
// longTermKeyReadFs records when longterm.age, the last file an unlock
// reads, was last opened.
type longTermKeyReadFs struct {
afero.Fs
readAt time.Time
}
//nolint:ireturn // afero.File is the interface required by afero.Fs
func (f *longTermKeyReadFs) Open(name string) (afero.File, error) {
if filepath.Base(name) == "longterm.age" {
f.readAt = time.Now()
}
return f.Fs.Open(name)
}
// TestVaultVersionTimestampsAfterUnlock checks that when AddSecret has to
// unlock the vault to replace a version, the new version's notBefore and the
// replaced version's notAfter are taken after the unlock, which can wait for
// the user to type the passphrase.
func TestVaultVersionTimestampsAfterUnlock(t *testing.T) {
t.Parallel()
fs := &longTermKeyReadFs{Fs: afero.NewMemMapFs()}
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
defer mnemonic.Destroy()
passphrase := memguard.NewBufferFromBytes([]byte("test-passphrase"))
defer passphrase.Destroy()
created, err := CreateVault(fs, testStateDir, "test", mnemonic, passphrase)
require.NoError(t, err)
addTestSecretToVault(t, created, testSecretPath, []byte("version-1"), false)
// A new instance is locked; AddSecret unlocks it with the passphrase
vault := NewVault(fs, testStateDir, "test")
vault.SetUnlockPassphrase(passphrase)
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
require.False(t, fs.readAt.IsZero(), "AddSecret did not unlock the vault")
ltIdentity, err := vault.GetOrDeriveLongTermKey()
require.NoError(t, err)
vaultDir, _ := vault.GetDirectory()
secretDir := filepath.Join(vaultDir, "secrets.d", "test%secret")
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 2)
newVersion := secret.NewVersion(vault, testSecretPath, versions[0])
require.NoError(t, newVersion.LoadMetadata(ltIdentity))
replacedVersion := secret.NewVersion(vault, testSecretPath, versions[1])
require.NoError(t, replacedVersion.LoadMetadata(ltIdentity))
assert.False(t, newVersion.Metadata.NotBefore.Before(fs.readAt))
assert.False(t, replacedVersion.Metadata.NotAfter.Before(fs.readAt))
}
func TestVaultGetNonExistentVersion(t *testing.T) {
t.Parallel()
+42 -22
View File
@@ -21,6 +21,7 @@ import (
// Unlocker metadata type strings.
const (
unlockerTypePassphrase = "passphrase"
unlockerTypePGP = "pgp"
)
// GetCurrentUnlocker returns the current unlocker for this vault
@@ -81,7 +82,7 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
passphraseUnlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
passphraseUnlocker.Passphrase = v.UnlockPassphrase
unlocker = passphraseUnlocker
case "pgp":
case unlockerTypePGP:
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, metadata)
@@ -128,9 +129,10 @@ func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, er
// checkCurrentUnlockerExists fails with ErrUnlockerNotFound, saying to
// select another unlocker, when unlockerDir, the unlocker directory that
// current-unlocker names, does not exist and the vault has other unlockers.
// When the vault has none, it returns nil: reading the missing unlocker then
// fails, with the advice that the mnemonic still opens the vault.
// current-unlocker names, does not exist and the vault has another unlocker
// that SelectUnlocker accepts. When it has none, it returns nil: reading the
// missing unlocker then fails, with the advice that the mnemonic still opens
// the vault.
func (v *Vault) checkCurrentUnlockerExists(unlockerDir string) error {
exists, err := afero.DirExists(v.fs, unlockerDir)
if err != nil {
@@ -141,12 +143,12 @@ func (v *Vault) checkCurrentUnlockerExists(unlockerDir string) error {
return nil
}
unlockers, err := v.ListUnlockers()
otherID, err := v.firstOtherUnlockerID(filepath.Base(unlockerDir))
if err != nil {
return err
}
if len(unlockers) == 0 {
if otherID == "" {
return nil
}
@@ -196,7 +198,7 @@ func (v *Vault) findUnlockerByID(
switch metadata.Type {
case unlockerTypePassphrase:
tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, metadata)
case "pgp":
case unlockerTypePGP:
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, metadata)
default:
continue
@@ -321,10 +323,9 @@ func (v *Vault) HasUnlocker(unlockerID string) (bool, error) {
// type is unknown, so only the directory is removed.
//
// It returns the ID of the unlocker it made current, or "" when it made none
// current. If the removed unlocker is the current one, the vault's first
// other unlocker in ID order, the order `secret unlocker list` shows, is
// made current before the removal; with no other, current-unlocker is
// removed after it.
// current. If the removed unlocker is the current one, the unlocker
// firstOtherUnlockerID returns is made current before the removal; with
// none, current-unlocker is removed after it.
func (v *Vault) RemoveUnlocker(unlockerID string) (string, error) {
vaultDir, err := v.GetDirectory()
if err != nil {
@@ -381,26 +382,45 @@ func (v *Vault) RemoveUnlocker(unlockerID string) (string, error) {
return newCurrentID, nil
}
// selectOtherUnlocker makes the vault's first unlocker in ID order other
// than unlockerID the current unlocker and returns its ID. It returns ""
// when the vault has no other unlocker.
// selectOtherUnlocker makes the unlocker firstOtherUnlockerID returns for
// unlockerID the current unlocker and returns its ID. It returns "" when
// there is no such unlocker.
func (v *Vault) selectOtherUnlocker(unlockerID string) (string, error) {
otherID, err := v.firstOtherUnlockerID(unlockerID)
if err != nil {
return "", err
}
if otherID == "" {
return "", nil
}
err = v.SelectUnlocker(otherID)
if err != nil {
return "", err
}
return otherID, nil
}
// firstOtherUnlockerID returns the ID of the vault's first unlocker other
// than unlockerID, in ID order, the order `secret unlocker list` shows, that
// SelectUnlocker accepts, or "" when there is none. SelectUnlocker refuses
// an unlocker whose metadata names a type other than passphrase or pgp,
// which ListUnlockers still returns.
func (v *Vault) firstOtherUnlockerID(unlockerID string) (string, error) {
unlockers, err := v.ListUnlockers()
if err != nil {
return "", err
}
for _, id := range slices.Sorted(maps.Keys(unlockers)) {
if id == unlockerID {
continue
}
unlockerType := unlockers[id].Type
err = v.SelectUnlocker(id)
if err != nil {
return "", err
if id != unlockerID && (unlockerType == unlockerTypePassphrase ||
unlockerType == unlockerTypePGP) {
return id, nil
}
return id, nil
}
return "", nil
+76
View File
@@ -5,6 +5,7 @@ import (
"path/filepath"
"testing"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
@@ -103,3 +104,78 @@ func TestAddSecretCleansUpOnFailure(t *testing.T) {
require.Len(t, entries, 1)
assert.Equal(t, "pub.age", entries[0].Name())
}
// TestAddSecretForceWithWrongPassphraseChangesNothing checks that adding a
// version to an existing secret, with a passphrase that does not open the
// vault, fails and leaves the secret's directory as it was.
func TestAddSecretForceWithWrongPassphraseChangesNothing(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
created, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t), passphrase)
require.NoError(t, err)
first := memguard.NewBufferFromBytes([]byte("first"))
t.Cleanup(first.Destroy)
require.NoError(t, created.AddSecret(testSecretName, first, false))
wrong := memguard.NewBufferFromBytes([]byte("wrong passphrase"))
t.Cleanup(wrong.Destroy)
vlt := vault.NewVault(fs, testStateDir, testVaultName)
vlt.SetUnlockPassphrase(wrong)
secretDir := filepath.Join(testStateDir, "vaults.d", testVaultName,
"secrets.d", testSecretName)
before := dirContents(t, fs, secretDir)
second := memguard.NewBufferFromBytes([]byte("second"))
t.Cleanup(second.Destroy)
err = vlt.AddSecret(testSecretName, second, true)
var noMatch *age.NoIdentityMatchError
require.ErrorAs(t, err, &noMatch)
assert.Equal(t, before, dirContents(t, fs, secretDir))
}
// dirContents maps every file under dir to its contents, and every
// directory, written with a trailing "/", to "".
func dirContents(t *testing.T, fs afero.Fs, dir string) map[string]string {
t.Helper()
contents := map[string]string{}
err := afero.Walk(fs, dir, func(
path string, info os.FileInfo, err error,
) error {
if err != nil {
return err
}
if info.IsDir() {
contents[path+"/"] = ""
return nil
}
content, err := afero.ReadFile(fs, path)
if err != nil {
return err
}
contents[path] = string(content)
return nil
})
require.NoError(t, err)
return contents
}