1 Commits
Author SHA1 Message Date
sneak a328e2487d Skip corrupt unlocker metadata in unlocker select and remove (closes #72)
check / check (push) Failing after 2s
findUnlockerByID failed on the first unlocker directory whose metadata
could not be checked, read or parsed, so `secret unlocker select` and
`remove` failed when one sorted before the unlocker asked for. It now
skips such a directory with the warning ListUnlockers gives, through
the code both now share. A skipped directory is removed by its
directory name, with RemoveDirAtomic, and cannot be selected.
`unlocker remove` applies the last-unlocker check only when the ID is
that of the single listed unlocker, so removing a skipped directory is
never refused as removing the last one.

Model: opus-5-5
2026-10-04 09:06:39 +00:00
5 changed files with 220 additions and 73 deletions
+3 -1
View File
@@ -198,7 +198,9 @@ Creates a new unlocker of the specified type:
**DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command **DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command
does not ask for confirmation. Cannot remove the last unlocker if the vault does not ask for confirmation. Cannot remove the last unlocker if the vault
has secrets unless --force is used. has secrets unless --force is used. 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.
- `--force, -f`: Force removal of last unlocker even if vault has secrets - `--force, -f`: Force removal of last unlocker even if vault has secrets
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase, - **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
vault data will be PERMANENTLY INACCESSIBLE vault data will be PERMANENTLY INACCESSIBLE
+8 -1
View File
@@ -25,6 +25,13 @@ Bring the repo into policy compliance in one commit:
# Completed Steps # Completed Steps
- 2026-10-04: `secret unlocker select` and `secret unlocker remove`
skip, with the warning `unlocker list` gives, an unlocker directory
whose metadata file cannot be checked for, read or parsed, instead of
failing when it sorts before the unlocker asked for. Such a directory,
or one without a metadata file, is removed by its directory name, the
name the warning gives; only the directory is removed, since its type
is unknown. Removing it never counts as removing the last unlocker.
- 2026-10-04: The `Makefile` no longer sets `DOCKER_HOST`, so its docker - 2026-10-04: The `Makefile` no longer sets `DOCKER_HOST`, so its docker
targets use the local docker daemon, or whatever `DOCKER_HOST` the targets use the local docker daemon, or whatever `DOCKER_HOST` the
environment sets. `make build` calls the new `script/build`, which environment sets. `make build` calls the new `script/build`, which
@@ -105,7 +112,7 @@ Bring the repo into policy compliance in one commit:
which `vault create` has already made the current vault; which `vault create` has already made the current vault;
- from an unlocker add stopped before its metadata is written, a - from an unlocker add stopped before its metadata is written, a
directory that `unlocker list` warns about and `unlocker rm` directory that `unlocker list` warns about and `unlocker rm`
cannot remove; removes only by its directory name;
- data under a `.tmp-` name in the state directory: a secret or - data under a `.tmp-` name in the state directory: a secret or
version being added, or the secret, version, unlocker or vault version being added, or the secret, version, unlocker or vault
being removed, encrypted keys included. Nothing deletes it; it being removed, encrypted keys included. Nothing deletes it; it
+20 -1
View File
@@ -744,14 +744,33 @@ func (cli *Instance) removeUnlocker(
return err return err
} }
// Get list of unlockers // Get list of unlockers. It leaves out a directory whose metadata
// cannot be read or parsed: that unlocker does not work, so removing it
// by its directory name never removes the last unlocker.
unlockers, err := vlt.ListUnlockers() unlockers, err := vlt.ListUnlockers()
if err != nil { if err != nil {
return fmt.Errorf("failed to list unlockers: %w", err) return fmt.Errorf("failed to list unlockers: %w", err)
} }
// Check if we're removing the last unlocker // Check if we're removing the last unlocker
removingLast := false
if len(unlockers) == 1 { if len(unlockers) == 1 {
vaultDir, err := vlt.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get vault directory: %w", err)
}
lastID, err := findUnlockerIDByMetadata(cli.fs,
filepath.Join(vaultDir, "unlockers.d"), unlockers[0], true)
if err != nil {
return err
}
removingLast = lastID == unlockerID
}
if removingLast {
// Check if vault has secrets // Check if vault has secrets
numSecrets, err := vlt.NumSecrets() numSecrets, err := vlt.NumSecrets()
if err != nil { if err != nil {
+112
View File
@@ -0,0 +1,112 @@
// Corrupt Unlocker Tests
//
// `secret unlocker select` and `secret unlocker remove` find an unlocker
// by its ID. These tests give the first unlocker, which sorts before the
// one the commands act on, metadata that is not JSON, and check that the
// commands step past it, and that it can itself be removed by its
// directory name, which `secret unlocker list` names in its warning.
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"path/filepath"
"testing"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newCorruptUnlockerVault returns the two-unlocker test vault with the
// metadata of the first unlocker replaced by text that is not JSON.
func newCorruptUnlockerVault(t *testing.T) *afero.MemMapFs {
t.Helper()
fs := newListTestVault(t, 2)
require.NoError(t, afero.WriteFile(fs,
filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName,
listTestUnlockerDirOne, listTestMetadataFileName),
[]byte("not json"), listTestFilePerm))
return fs
}
// TestUnlockerSelectSkipsCorruptUnlocker asserts that the second unlocker
// can be selected, and that the corrupt one, having no type to be used as,
// cannot be selected by its directory name.
func TestUnlockerSelectSkipsCorruptUnlocker(t *testing.T) {
t.Parallel()
fs := newCorruptUnlockerVault(t)
instance, _ := newTestInstance(fs)
require.NoError(t, instance.UnlockerSelect("pgp-"+listTestGPGKeyID+"B"))
current, err := afero.ReadFile(fs,
filepath.Join(testVaultDir(listTestVaultName), "current-unlocker"))
require.NoError(t, err)
assert.Equal(t, listTestUnlockerDirTwo, string(current))
err = instance.UnlockerSelect(listTestUnlockerDirOne)
require.ErrorIs(t, err, vault.ErrUnlockerNotFound)
}
// TestUnlockerRemoveWithCorruptUnlocker asserts that the second unlocker
// can be removed, unless the vault holds secrets: the corrupt unlocker
// cannot unlock the vault, so the second is its last. The corrupt one can
// be removed by its directory name without --force even then.
func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) {
t.Parallel()
tests := []struct {
name string
unlockerID string
withSecret bool
wantErr error
wantEntries []string
}{
{
name: "the other unlocker",
unlockerID: "pgp-" + listTestGPGKeyID + "B",
wantEntries: []string{listTestUnlockerDirOne},
},
{
name: "the other unlocker, the last one, with secrets",
unlockerID: "pgp-" + listTestGPGKeyID + "B",
withSecret: true,
wantErr: errLastUnlocker,
wantEntries: []string{
listTestUnlockerDirOne, listTestUnlockerDirTwo,
},
},
{
name: "the corrupt unlocker by its directory name",
unlockerID: listTestUnlockerDirOne,
withSecret: true,
wantEntries: []string{listTestUnlockerDirTwo},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := newCorruptUnlockerVault(t)
if tt.withSecret {
writeTestSecret(t, fs, testVaultDir(listTestVaultName))
}
instance, cmd := newTestInstance(fs)
err := instance.UnlockersRemove(tt.unlockerID, false, cmd)
require.ErrorIs(t, err, tt.wantErr)
assertDirEntries(t, fs,
filepath.Join(testVaultDir(listTestVaultName),
listTestUnlockersDirName),
tt.wantEntries...)
})
}
}
+77 -70
View File
@@ -126,7 +126,11 @@ func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, er
} }
// findUnlockerByID finds an unlocker by its ID and returns the unlocker // findUnlockerByID finds an unlocker by its ID and returns the unlocker
// instance and its directory path // instance and its directory path. A directory that ListUnlockers skips is
// skipped here too, with the same warning. Such a directory has no ID: if
// no unlocker has the ID unlockerID but such a directory is named
// unlockerID, that directory is returned with a nil unlocker, so that
// RemoveUnlocker can remove it.
// //
//nolint:ireturn // returns one of several concrete unlocker implementations //nolint:ireturn // returns one of several concrete unlocker implementations
func (v *Vault) findUnlockerByID( func (v *Vault) findUnlockerByID(
@@ -137,42 +141,24 @@ func (v *Vault) findUnlockerByID(
return nil, "", fmt.Errorf("failed to read unlockers directory: %w", err) return nil, "", fmt.Errorf("failed to read unlockers directory: %w", err)
} }
skippedDirPath := ""
for _, file := range files { for _, file := range files {
if !file.IsDir() { if !file.IsDir() {
continue continue
} }
// Read metadata file unlockerDirPath := filepath.Join(unlockersDir, file.Name())
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath) metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
if err != nil { if !ok {
return nil, "", fmt.Errorf( if file.Name() == unlockerID {
"failed to check if metadata exists for unlocker %s: %w", skippedDirPath = unlockerDirPath
file.Name(), err) }
}
if !exists {
// Skip directories without metadata - they might not be unlockers
continue continue
} }
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
return nil, "", fmt.Errorf(
"failed to read metadata for unlocker %s: %w", file.Name(), err)
}
var metadata UnlockerMetadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
return nil, "", fmt.Errorf(
"failed to parse metadata for unlocker %s: %w", file.Name(), err)
}
unlockerDirPath := filepath.Join(unlockersDir, file.Name())
// Create the appropriate unlocker instance // Create the appropriate unlocker instance
var tempUnlocker secret.Unlocker var tempUnlocker secret.Unlocker
@@ -195,7 +181,7 @@ func (v *Vault) findUnlockerByID(
} }
} }
return nil, "", nil return nil, skippedDirPath, nil
} }
// ListUnlockers returns a list of available unlockers for this vault // ListUnlockers returns a list of available unlockers for this vault
@@ -226,44 +212,12 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
var unlockers []UnlockerMetadata var unlockers []UnlockerMetadata
for _, file := range files { for _, file := range files {
if file.IsDir() { if !file.IsDir() {
// Read metadata file continue
metadataPath := filepath.Join(unlockersDir, file.Name(), }
"unlocker-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil {
secret.Warn("Skipping unlocker directory whose metadata file cannot be checked",
"directory", file.Name(), "error", err)
continue
}
if !exists {
secret.Warn("Skipping unlocker directory with missing metadata file",
"directory", file.Name())
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
secret.Warn("Skipping unlocker directory with unreadable metadata file",
"directory", file.Name(), "error", err)
continue
}
var metadata UnlockerMetadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
secret.Warn("Skipping unlocker directory with corrupt metadata file",
"directory", file.Name(), "error", err)
continue
}
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
if ok {
unlockers = append(unlockers, metadata) unlockers = append(unlockers, metadata)
} }
} }
@@ -271,7 +225,54 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
return unlockers, nil return unlockers, nil
} }
// RemoveUnlocker removes an unlocker from this vault // readUnlockerMetadataOrWarn reads the metadata of the unlocker directory
// name in unlockersDir. If the metadata file cannot be checked for, is
// missing, or cannot be read or parsed, it warns, naming the directory,
// and returns false: the caller skips that directory.
func (v *Vault) readUnlockerMetadataOrWarn(
unlockersDir, name string,
) (UnlockerMetadata, bool) {
metadataPath := filepath.Join(unlockersDir, name, "unlocker-metadata.json")
var metadata UnlockerMetadata
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil {
secret.Warn("Skipping unlocker directory whose metadata file cannot be checked",
"directory", name, "error", err)
return metadata, false
}
if !exists {
secret.Warn("Skipping unlocker directory with missing metadata file",
"directory", name)
return metadata, false
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
secret.Warn("Skipping unlocker directory with unreadable metadata file",
"directory", name, "error", err)
return metadata, false
}
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
secret.Warn("Skipping unlocker directory with corrupt metadata file",
"directory", name, "error", err)
return metadata, false
}
return metadata, true
}
// RemoveUnlocker removes an unlocker from this vault. An unlocker
// directory that ListUnlockers skips is removed by its directory name; its
// type is unknown, so only the directory is removed.
func (v *Vault) RemoveUnlocker(unlockerID string) error { func (v *Vault) RemoveUnlocker(unlockerID string) error {
vaultDir, err := v.GetDirectory() vaultDir, err := v.GetDirectory()
if err != nil { if err != nil {
@@ -282,15 +283,19 @@ func (v *Vault) RemoveUnlocker(unlockerID string) error {
unlockersDir := filepath.Join(vaultDir, "unlockers.d") unlockersDir := filepath.Join(vaultDir, "unlockers.d")
// Find the unlocker by ID // Find the unlocker by ID
unlocker, _, err := v.findUnlockerByID(unlockersDir, unlockerID) unlocker, unlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
if err != nil { if err != nil {
return err return err
} }
if unlocker == nil { if unlockerDir == "" {
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound) return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
} }
if unlocker == nil {
return secret.RemoveDirAtomic(v.fs, unlockerDir)
}
// Use the unlocker's Remove method // Use the unlocker's Remove method
return unlocker.Remove() return unlocker.Remove()
} }
@@ -306,12 +311,14 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
unlockersDir := filepath.Join(vaultDir, "unlockers.d") unlockersDir := filepath.Join(vaultDir, "unlockers.d")
// Find the unlocker by ID // Find the unlocker by ID
_, targetUnlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID) unlocker, targetUnlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
if err != nil { if err != nil {
return err return err
} }
if targetUnlockerDir == "" { // A directory found without an unlocker is one ListUnlockers skips; it
// cannot be selected.
if unlocker == nil {
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound) return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
} }