Give every new unlocker a directory of its own (closes #71)
check / check (push) Waiting to run

A passphrase unlocker added to a vault that had one, and a PGP, keychain
or Secure Enclave unlocker added on the same day as another of its type,
were written into the existing unlocker's directory file by file, so a
crash part-way left a current unlocker whose files did not belong
together.

Unlocker directories, keychain items and Secure Enclave keys are now
named with the time to the nanosecond, and secret.WriteDir refuses a
directory that exists. Adding a passphrase unlocker writes the new one,
points current-unlocker at it, and only then removes the vault's other
passphrase unlockers.

Model: opus-5-5
This commit is contained in:
2026-10-04 13:11:35 +00:00
parent 62967f28d0
commit 2823d93cc3
12 changed files with 293 additions and 67 deletions
+6 -3
View File
@@ -197,6 +197,9 @@ Creates a new unlocker of the specified type:
**Options:**
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified)
A vault has one passphrase unlocker: adding one replaces the one the vault
has, which is removed only once the new one is the current unlocker.
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
**DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command
@@ -243,8 +246,8 @@ Decrypts data using an Age key stored as a secret.
├── vaults.d/
│ ├── default/
│ │ ├── unlockers.d/
│ │ │ ├── passphrase/ # Passphrase unlocker
│ │ │ └── pgp/ # PGP unlocker
│ │ │ ├── passphrase-<time>/ # Passphrase unlocker
│ │ │ └── <host>-pgp-<time>/ # PGP unlocker
│ │ ├── secrets.d/
│ │ │ ├── api%key/ # Secret: api/key
│ │ │ │ ├── versions/
@@ -260,7 +263,7 @@ Decrypts data using an Age key stored as a secret.
│ │ │ └── current -> versions/20231215.001
│ │ ├── vault-metadata.json # Vault metadata
│ │ ├── pub.age # Long-term public key
│ │ └── current-unlocker -> ../unlockers.d/passphrase
│ │ └── current-unlocker # Current unlocker's directory name
│ └── work/
│ ├── unlockers.d/
│ ├── secrets.d/
+17 -9
View File
@@ -25,6 +25,22 @@ Bring the repo into policy compliance in one commit:
# Completed Steps
- 2026-10-04: A crash while an unlocker is being replaced no longer
leaves a current unlocker that cannot open the vault
(https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets
a directory of its own, named with the time to the nanosecond:
`passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure
Enclave unlocker the keychain item or Secure Enclave key, which names
the directory, carries the time instead of the day. `secret.WriteDir`
fails on a directory that exists instead of writing into it.
`unlocker add passphrase` writes the new unlocker, makes it current,
and only then removes the vault's other passphrase unlockers; a crash
between the last two steps leaves the old one beside the new, and the
old passphrase still opens the vault through it until the next
`unlocker add passphrase` or an `unlocker remove` removes it. A PGP,
keychain or
Secure Enclave unlocker added on the same host and day as another of
its type is added beside it instead of replacing it.
- 2026-10-04: `secret unlocker add pgp` works on Linux
(https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets
the vault's long-term key as adding a passphrase unlocker does, with the
@@ -59,9 +75,7 @@ Bring the repo into policy compliance in one commit:
and encrypt everything before writing anything. All four unlocker
types write their files through `secret.WriteDir`: a new unlocker is
built in a temporary directory, renamed into place when complete and
removed on a failure. One added under the directory name of an
existing unlocker is still written into that directory in place
(https://git.eeqj.de/sneak/secret/issues/71).
removed on a failure.
- 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
@@ -157,12 +171,6 @@ Bring the repo into policy compliance in one commit:
into place, and removals rename out of the way first, so a version
or secret is never half-added and never half-removed. An
interrupted command can still leave:
- a broken unlocker, when it was replacing one: an unlocker added
under the directory name of an existing one is rewritten file by
file. That happens to a passphrase unlocker added to a vault that
has one, and to a PGP, keychain or Secure Enclave unlocker added
on the same host and day as another of its type
(https://git.eeqj.de/sneak/secret/issues/71);
- from `init` or `vault create` killed after the passphrase prompt
but before the unlocker is written, a vault with no unlocker,
which `vault create` has already made the current vault;
+10 -9
View File
@@ -366,8 +366,15 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
unlockersDir := filepath.Join(defaultVaultDir, "unlockers.d")
verifyFileExists(t, unlockersDir)
// Check current-unlocker file names the unlocker's directory
currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
verifyFileExists(t, currentUnlockerFile)
currentUnlockerContent := readFile(t, currentUnlockerFile)
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should point to passphrase type")
// Verify passphrase unlocker was created
passphraseUnlockerDir := filepath.Join(unlockersDir, "passphrase")
passphraseUnlockerDir := filepath.Join(unlockersDir, string(currentUnlockerContent))
verifyFileExists(t, passphraseUnlockerDir)
// Check unlocker metadata
@@ -382,13 +389,6 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age")
verifyFileExists(t, encryptedLTPubKey)
// Check current-unlocker file contains the relative path
currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
verifyFileExists(t, currentUnlockerFile)
currentUnlockerContent := readFile(t, currentUnlockerFile)
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should point to passphrase type")
// Verify vault-metadata.json in vault
vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json")
verifyFileExists(t, vaultMetadata)
@@ -537,7 +537,8 @@ func test04ImportMnemonic(t *testing.T, tempDir, testMnemonic, testPassphrase st
verifyFileExists(t, pubKeyFile)
// Verify passphrase unlocker was created
passphraseUnlockerDir := filepath.Join(workVaultDir, "unlockers.d", "passphrase")
currentUnlocker := readFile(t, filepath.Join(workVaultDir, "current-unlocker"))
passphraseUnlockerDir := filepath.Join(workVaultDir, "unlockers.d", string(currentUnlocker))
verifyFileExists(t, passphraseUnlockerDir)
// Check unlocker files
+1 -1
View File
@@ -171,7 +171,7 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
vaultDir := testStateDir + "/vaults.d/default"
require.Contains(t, before, vaultDir+"/secrets.d/x/")
require.Contains(t, before, vaultDir+"/unlockers.d/passphrase/")
require.Contains(t, before, vaultDir+"/current-unlocker")
require.Equal(t, "default", before[testStateDir+"/currentvault"])
cmd := &cobra.Command{}
+8 -8
View File
@@ -3,6 +3,7 @@ package secret
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/spf13/afero"
@@ -62,13 +63,12 @@ func TempDirFor(fs afero.Fs, target string) (string, error) {
return dir, nil
}
// WriteDir calls write to write the files of the directory dir. When dir does
// not exist yet, write writes them into a temporary directory from TempDirFor,
// which is then renamed to dir, so that neither a failure nor a crash leaves
// dir half-written; on a failure the temporary directory is removed, and a
// failure to remove it is returned along with the first. A directory cannot be
// renamed over one that has files in it, so when dir already exists, write
// writes into it in place; dir is then never removed.
// WriteDir calls write to write the files of the new directory dir into a
// temporary directory from TempDirFor, which is then renamed to dir, so that
// neither a failure nor a crash leaves dir half-written; on a failure the
// temporary directory is removed, and a failure to remove it is returned
// along with the first. A directory cannot be replaced in one rename, so if
// dir already exists, WriteDir fails without calling write.
func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error {
exists, err := afero.Exists(fs, dir)
if err != nil {
@@ -76,7 +76,7 @@ func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error {
}
if exists {
return write(dir)
return fmt.Errorf("failed to create %s: %w", dir, os.ErrExist)
}
// Create the directory the finished one is renamed into
+137 -17
View File
@@ -191,6 +191,22 @@ func dirNames(t *testing.T, fs afero.Fs, dir string) []string {
return names
}
// dirFiles returns the contents of the files in dir, by name.
func dirFiles(t *testing.T, fs afero.Fs, dir string) map[string]string {
t.Helper()
files := map[string]string{}
for _, name := range dirNames(t, fs, dir) {
data, err := afero.ReadFile(fs, filepath.Join(dir, name))
require.NoError(t, err)
files[name] = string(data)
}
return files
}
// writeLongTermKey gives the test vault under stateDir a new long-term key
// and returns it.
func writeLongTermKey(
@@ -666,14 +682,14 @@ func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
unlockerDir := filepath.Join(vaultDir, "unlockers.d", "passphrase")
// The vault has no unlocker yet, so any directory in here is
// the new one
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
fs := hookFs{Fs: base, before: func(string, string) error {
exists, err := afero.DirExists(base, unlockerDir)
require.NoError(t, err)
if exists {
assert.ElementsMatch(t, files, dirNames(t, base, unlockerDir),
for _, name := range dirNames(t, base, unlockersDir) {
assert.ElementsMatch(t, files,
dirNames(t, base, filepath.Join(unlockersDir, name)),
"unlocker directory visible before it was complete")
}
@@ -683,14 +699,118 @@ func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
defer passphrase.Destroy()
_, err = vault.NewVault(fs, stateDir, testVaultName).
unlocker, err := vault.NewVault(fs, stateDir, testVaultName).
CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
assert.ElementsMatch(t, files, dirNames(t, base, unlockerDir))
assert.ElementsMatch(t, files, dirNames(t, base, unlocker.GetDirectory()))
})
}
}
// TestPassphraseUnlockerReplacementKeepsVaultOpen replaces the vault's
// passphrase unlocker, failing at each change the replacement makes in turn,
// until an attempt makes all of its changes; each attempt starts from what
// the one before left. Both at the change that fails, which is where a crash
// would leave the vault, and after the attempt returns, the vault must open
// with the passphrase through its current unlocker. Once an attempt
// succeeds, the vault must have one passphrase unlocker left.
func TestPassphraseUnlockerReplacementKeepsVaultOpen(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, unlockerPassphrase)
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
base, stateDir := tfs.open(t)
vlt, err := vault.CreateVault(base, stateDir, testVaultName)
require.NoError(t, err)
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
require.NoError(t, err)
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
defer passphrase.Destroy()
_, err = vlt.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
// From here on only the current unlocker opens the vault
t.Setenv(secret.EnvMnemonic, "")
assertOpens := vaultOpensCheck(t, base, stateDir, ltIdentity)
for failAt := 1; ; failAt++ {
changes := 0
fs := hookFs{Fs: base, before: func(string, string) error {
changes++
if changes != failAt {
return nil
}
assertOpens()
return errInjected
}}
replacing := vault.NewVault(fs, stateDir, testVaultName)
replacing.Unlock(ltIdentity)
_, err = replacing.CreatePassphraseUnlocker(passphrase)
assertOpens()
if changes < failAt {
require.NoError(t, err)
break
}
require.ErrorIs(t, err, errInjected)
}
unlockers, err := vlt.ListUnlockers()
require.NoError(t, err)
assert.Len(t, unlockers, 1)
})
}
}
// vaultOpensCheck returns a function that checks that the test vault under
// stateDir opens through its current unlocker, with the test passphrase, to
// the long-term key ltIdentity. Opening it takes a second, so an unlocker
// directory it has opened through before is not opened again: it must hold
// the same files as then.
func vaultOpensCheck(
t *testing.T, fs afero.Fs, stateDir string, ltIdentity *age.X25519Identity,
) func() {
t.Helper()
vaultDir := filepath.Join(stateDir, "vaults.d", testVaultName)
// The files of each unlocker directory the vault has opened through
opened := map[string]map[string]string{}
return func() {
t.Helper()
current, err := afero.ReadFile(fs, filepath.Join(vaultDir, "current-unlocker"))
require.NoError(t, err)
files := dirFiles(t, fs, filepath.Join(vaultDir, "unlockers.d", string(current)))
if before, ok := opened[string(current)]; ok {
assert.Equal(t, before, files, "unlocker changed since it opened the vault")
return
}
key, err := vault.NewVault(fs, stateDir, testVaultName).UnlockVault()
require.NoError(t, err)
assert.Equal(t, ltIdentity.Recipient().String(), key.Recipient().String())
opened[string(current)] = files
}
}
// TestWriteDirFailureLeavesNothing makes writing a new directory fail after
// a file has been written in it, and checks that neither the directory nor
// its temporary directory is left behind; and, when the temporary directory
@@ -736,10 +856,10 @@ func TestWriteDirFailureLeavesNothing(t *testing.T) {
}
}
// TestWriteDirKeepsExistingDir makes writing into a directory that already
// exists fail, and checks that the directory, with what was in it, is still
// there: WriteDir writes into it in place and never removes it.
func TestWriteDirKeepsExistingDir(t *testing.T) {
// TestWriteDirRefusesExistingDir checks that WriteDir fails, without calling
// write, when the directory already exists, and leaves the directory as it
// was: it never writes into a directory in place.
func TestWriteDirRefusesExistingDir(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
@@ -747,17 +867,17 @@ func TestWriteDirKeepsExistingDir(t *testing.T) {
t.Parallel()
fs, dir := tfs.open(t)
target := filepath.Join(dir, "unlockers.d", "passphrase")
target := filepath.Join(dir, "unlockers.d", "existing")
require.NoError(t, fs.MkdirAll(target, secret.DirPerms))
require.NoError(t, secret.WriteFileAtomic(fs,
filepath.Join(target, unlockerMetadataFile), []byte("{}")))
err := secret.WriteDir(fs, target, func(got string) error {
assert.Equal(t, target, got)
err := secret.WriteDir(fs, target, func(string) error {
t.Error("write called for a directory that exists")
return errInjected
return nil
})
require.ErrorIs(t, err, errInjected)
require.ErrorIs(t, err, os.ErrExist)
assert.Equal(t, []string{unlockerMetadataFile}, dirNames(t, fs, target))
})
}
+6
View File
@@ -16,6 +16,12 @@ const (
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE"
// EnvGPGKeyID is the environment variable for providing the GPG key ID
EnvGPGKeyID = "SB_GPG_KEY_ID"
// UnlockerTimeFormat is the layout of the time, in UTC, in the name of a
// new unlocker's directory, keychain item and Secure Enclave key. It runs
// to the nanosecond, so that every new unlocker, even one added right
// after another, gets a directory of its own.
UnlockerTimeFormat = "2006-01-02.15.04.05.000000000"
)
// File system permission constants
+3 -3
View File
@@ -233,10 +233,10 @@ func generateKeychainUnlockerName(vaultName string) (string, error) {
return "", fmt.Errorf("failed to get hostname: %w", err)
}
// Format: secret-<vault>-<hostname>-<date>
enrollmentDate := time.Now().Format("2006-01-02")
// Format: secret-<vault>-<hostname>-<time>
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentTime), nil
}
// getLongTermPrivateKey retrieves the long-term private key either from environment or current unlocker
+4 -5
View File
@@ -209,21 +209,20 @@ func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
}
// generatePGPUnlockerName generates a unique name for the PGP unlocker
// based on hostname and date
// based on hostname and time
func generatePGPUnlockerName() (string, error) {
hostname, err := os.Hostname()
if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err)
}
// Format: hostname-pgp-YYYY-MM-DD
enrollmentDate := time.Now().Format("2006-01-02")
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentTime), nil
}
// pgpUnlockerDir returns the current vault and the directory in it for a
// new PGP unlocker, named after the host and the day.
// new PGP unlocker, named after the host and the time.
//
//nolint:ireturn // the vault is only available behind VaultInterface
func pgpUnlockerDir(
+36
View File
@@ -7,6 +7,7 @@ import (
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -63,3 +64,38 @@ func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
require.NoError(t, err)
assert.Empty(t, dirNames(t, base, filepath.Join(vaultDir, "unlockers.d")))
}
// TestPGPUnlockerAddedTwiceKeepsFirst adds two PGP unlockers one right after
// the other, so on the same host and day, and checks that the second gets a
// directory of its own and leaves the first one's files as they were.
// CreatePGPUnlocker does not check whether the GPG key already has an
// unlocker, so the test key serves for both.
func TestPGPUnlockerAddedTwiceKeepsFirst(t *testing.T) {
installFakeGPG(t)
t.Setenv(secret.EnvMnemonic, testMnemonic)
original := secret.GPGEncryptFunc
t.Cleanup(func() { secret.GPGEncryptFunc = original })
// Stands in for gpg, which the test does not have: "encrypts" by copying
secret.GPGEncryptFunc = func(data *memguard.LockedBuffer, _ string) ([]byte, error) {
return []byte(data.String()), nil
}
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testVaultStateDir, testVaultName)
require.NoError(t, err)
first, err := secret.CreatePGPUnlocker(
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint)
require.NoError(t, err)
firstFiles := dirFiles(t, fs, first.GetDirectory())
second, err := secret.CreatePGPUnlocker(
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint)
require.NoError(t, err)
assert.NotEqual(t, first.GetDirectory(), second.GetDirectory())
assert.Equal(t, firstFiles, dirFiles(t, fs, first.GetDirectory()))
}
+2 -2
View File
@@ -193,14 +193,14 @@ func generateSEKeyLabel(vaultName string) (string, error) {
return "", fmt.Errorf("failed to get hostname: %w", err)
}
enrollmentDate := time.Now().UTC().Format("2006-01-02")
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
return fmt.Sprintf(
"%s.%s-%s-%s",
seKeyLabelPrefix,
vaultName,
hostname,
enrollmentDate,
enrollmentTime,
), nil
}
+63 -10
View File
@@ -2,8 +2,10 @@ package vault
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
@@ -101,7 +103,7 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
// resolveUnlockerDirectory reads the current-unlocker file to get the
// unlocker directory path
// The file contains just the unlocker name (e.g., "passphrase")
// The file contains just the name of the unlocker's directory in unlockers.d
func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath)
@@ -339,7 +341,10 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
return nil
}
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker in a
// directory of its own, makes it the current unlocker, and only then removes
// the vault's other passphrase unlockers: a vault keeps one. A crash at any
// point leaves a complete current unlocker, the old one or the new.
// The passphrase must be provided as a LockedBuffer for security
func (v *Vault) CreatePassphraseUnlocker(
passphrase *memguard.LockedBuffer,
@@ -351,13 +356,23 @@ func (v *Vault) CreatePassphraseUnlocker(
// We need to get the long-term key (either from memory if unlocked, or
// derive it). Getting it before anything is written means failing to
// get it changes nothing, even when replacing the current unlocker.
// get it changes nothing.
ltIdentity, err := v.GetOrDeriveLongTermKey()
if err != nil {
return nil, fmt.Errorf("failed to get long-term key: %w", err)
}
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerTypePassphrase)
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
// The passphrase unlockers the new one replaces
oldDirs, err := v.passphraseUnlockerDirs(unlockersDir)
if err != nil {
return nil, err
}
createdAt := time.Now()
unlockerDir := filepath.Join(unlockersDir, unlockerTypePassphrase+"-"+
createdAt.UTC().Format(secret.UnlockerTimeFormat))
// Generate new age keypair for unlocker
unlockerIdentity, err := age.GenerateX25519Identity()
@@ -377,7 +392,7 @@ func (v *Vault) CreatePassphraseUnlocker(
metadata := UnlockerMetadata{
Type: unlockerTypePassphrase,
CreatedAt: time.Now(),
CreatedAt: createdAt,
Flags: []string{},
}
@@ -395,16 +410,54 @@ func (v *Vault) CreatePassphraseUnlocker(
return nil, err
}
// Create the unlocker instance
unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
// Select the new unlocker by its directory, not by its ID: an old
// passphrase unlocker created in the same minute has the same ID.
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
// Select this unlocker as current
err = v.SelectUnlocker(unlocker.GetID())
err = secret.WriteFileAtomic(v.fs, currentUnlockerPath,
[]byte(filepath.Base(unlockerDir)))
if err != nil {
return nil, fmt.Errorf("failed to select new unlocker: %w", err)
}
return unlocker, nil
for _, oldDir := range oldDirs {
err = secret.RemoveDirAtomic(v.fs, oldDir)
if err != nil {
return nil, fmt.Errorf(
"created and selected the new passphrase unlocker: %w", err)
}
}
return secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata), nil
}
// passphraseUnlockerDirs returns the directories in unlockersDir that hold
// passphrase unlockers. A directory ListUnlockers skips is left out, with the
// same warning.
func (v *Vault) passphraseUnlockerDirs(unlockersDir string) ([]string, error) {
files, err := afero.ReadDir(v.fs, unlockersDir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to read unlockers directory: %w", err)
}
var dirs []string
for _, file := range files {
if !file.IsDir() {
continue
}
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
if ok && metadata.Type == unlockerTypePassphrase {
dirs = append(dirs, filepath.Join(unlockersDir, file.Name()))
}
}
return dirs, nil
}
// readUnlockerMetadata reads and parses the unlocker-metadata.json file in