Compare commits

...
3 Commits
Author SHA1 Message Date
sneak b9d2797840 Skip corrupt unlocker metadata in unlocker select and remove (closes #72)
check / check (push) Failing after 1s
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. Removing
one whose metadata file is missing or corrupt never counts as removing
the last unlocker; removing one whose metadata file cannot be checked
for or read does, since it may be the only working unlocker.

Model: opus-5-5
2026-10-04 10:09:52 +00:00
clawbot 1ec0423e6e Print usage only for a command called wrongly (closes #41)
check / check (push) Failing after 4s
A failed command printed the whole usage text after its error, burying
it. The root command's PersistentPreRunE now turns usage off, so an
error from running the command is printed once on its own. Wrong arity,
an unknown flag, a bad flag value, a missing required flag and broken
flag groups still get usage: cobra checks arguments and flag values
before that hook but required flags and flag groups only after it, so
the hook checks those two first. Root SilenceUsage was not used: in
this cobra version it hides usage for argument and flag errors too.
Cobra still prints the error; Entry is unchanged.

Model: opus-5-5
2026-10-04 12:08:06 +02:00
clawbot 4ed77902d1 Keep secret get values in locked memory (closes #37)
check / check (push) Failing after 19s
Vault.GetSecret and Vault.GetSecretVersion return the decrypted value
as a *memguard.LockedBuffer instead of copying it into an ordinary
[]byte that nothing wiped. Every caller destroys the buffer, and
`secret get` writes its bytes straight to stdout, still with no
trailing newline. Instance.Print, which formatted through fmt and had
no other callers, is removed, and so is a debug log line in
`get --version` that held the plaintext value.

Model: opus-5-5
2026-10-04 11:16:23 +02:00
20 changed files with 557 additions and 150 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
+27 -3
View File
@@ -25,6 +25,32 @@ 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 one whose metadata file is missing or corrupt
never counts as removing the last unlocker. Removing one whose metadata
file cannot be checked for or read always does, since it may be the
only working unlocker, so in a vault with secrets it needs `--force`.
- 2026-10-04: A failed command prints its error once, without the usage
text after it (https://git.eeqj.de/sneak/secret/issues/41). Usage is
still printed for a command called wrongly: wrong number of arguments,
unknown flag, bad flag value, missing required flag, or flags that
break a flag group (mutually exclusive, required together, one
required). The root command's `PersistentPreRunE` turns usage off.
Cobra checks arguments and flag values before that hook but required
flags and flag groups only after it, so the hook checks those two
first. Root `SilenceUsage` would have hidden usage for all of these.
- 2026-10-04: `secret get` keeps the secret in locked memory until it
writes it out (https://git.eeqj.de/sneak/secret/issues/37):
`Vault.GetSecret` and `Vault.GetSecretVersion` return a
`*memguard.LockedBuffer`, which every caller destroys, and `secret get`
writes its bytes straight to stdout, still with no trailing newline.
Before, the value was copied into ordinary memory that nothing wiped,
and `get --version` also wrote it to the debug log.
- 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 +131,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
@@ -217,8 +243,6 @@ Bring the repo into policy compliance in one commit:
209-216); non-constant-time public key compare (vault.go:95-100). 209-216); non-constant-time public key compare (vault.go:95-100).
- High priority: - High priority:
- Secure temporary file handling and cleanup. - Secure temporary file handling and cleanup.
- Print cobra usage only for argument errors, not internal
failures.
- Initialize a default unlock key at vault creation. - Initialize a default unlock key at vault creation.
- Confirmation prompts for destructive operations (keys rm, vault - Confirmation prompts for destructive operations (keys rm, vault
deletion). deletion).
-5
View File
@@ -68,8 +68,3 @@ func (cli *Instance) SetStateDir(stateDir string) {
func (cli *Instance) GetStateDir() string { func (cli *Instance) GetStateDir() string {
return cli.stateDir return cli.stateDir
} }
// Print outputs to the command's configured output writer
func (cli *Instance) Print(a ...any) (int, error) {
return fmt.Fprint(cli.cmd.OutOrStdout(), a...)
}
+6 -1
View File
@@ -1,6 +1,7 @@
package cli_test package cli_test
import ( import (
"bytes"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli" "git.eeqj.de/sneak/secret/internal/cli"
@@ -89,7 +90,11 @@ func TestCreateExistingVaultChangesNothing(t *testing.T) {
for _, name := range vaults { for _, name := range vaults {
value, err := vault.NewVault(fs, testStateDir, name).GetSecret("x") value, err := vault.NewVault(fs, testStateDir, name).GetSecret("x")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "value", string(value))
unchanged := bytes.Equal([]byte("value"), value.Bytes())
value.Destroy()
require.True(t, unchanged, "vault %q kept its secret", name)
} }
} }
+2 -1
View File
@@ -147,7 +147,8 @@ func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
value, err := vlt.GetSecretVersion("shared", version) value, err := vlt.GetSecretVersion("shared", version)
require.NoError(t, err) require.NoError(t, err)
values[string(value)] = true values[string(value.Bytes())] = true
value.Destroy()
} }
assert.Len(t, values, adds+1, "every add stored its own value") assert.Len(t, values, adds+1, "every add stored its own value")
+8 -2
View File
@@ -178,7 +178,10 @@ func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
value, err := vlt.GetSecret("x") value, err := vlt.GetSecret("x")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "value", string(value))
defer value.Destroy()
require.Equal(t, []byte("value"), value.Bytes())
target, err := os.Readlink(link) target, err := os.Readlink(link)
require.NoError(t, err) require.NoError(t, err)
@@ -222,7 +225,10 @@ func TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem(t *testing.T) {
value, err := vlt.GetSecret("foo") value, err := vlt.GetSecret("foo")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "upper", string(value))
defer value.Destroy()
require.Equal(t, []byte("upper"), value.Bytes())
_, err = vlt.GetSecret("Foo") _, err = vlt.GetSecret("Foo")
require.ErrorIs(t, err, vault.ErrSecretNotFound) require.ErrorIs(t, err, vault.ErrSecretNotFound)
+23 -2
View File
@@ -46,9 +46,30 @@ func newRootCmd() *cobra.Command {
Short: "A simple secrets manager", Short: "A simple secrets manager",
Long: `A simple secrets manager to store and retrieve sensitive ` + Long: `A simple secrets manager to store and retrieve sensitive ` +
`information securely.`, `information securely.`,
// Ensure usage is shown after errors // Cobra prints the error a command returns; Entry does not.
SilenceUsage: false,
SilenceErrors: false, SilenceErrors: false,
// Usage belongs only to a command called wrongly. Cobra has
// checked its arguments and flag values before this runs, but
// checks required flags (ValidateRequiredFlags) and flag groups
// (ValidateFlagGroups) only after it, so both are checked here
// to keep usage for them. An error after that comes from running
// the command, and usage would only bury it. A subcommand that
// sets its own PersistentPreRun replaces this one.
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
err := cmd.ValidateRequiredFlags()
if err != nil {
return err
}
err = cmd.ValidateFlagGroups()
if err != nil {
return err
}
cmd.SilenceUsage = true
return nil
},
} }
secret.Debug("Adding subcommands to root command") secret.Debug("Adding subcommands to root command")
+7 -19
View File
@@ -414,9 +414,6 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error { func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
secret.Debug("GetSecret called", "secretName", secretName) secret.Debug("GetSecret called", "secretName", secretName)
// Store the command for output
cli.cmd = cmd
// Get current vault // Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil { if err != nil {
@@ -427,9 +424,10 @@ func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
if err != nil { if err != nil {
return err return err
} }
defer value.Destroy()
// Print the secret value to stdout // Write the value straight from locked memory, with no trailing newline
_, _ = cli.Print(string(value)) _, _ = cmd.OutOrStdout().Write(value.Bytes())
return nil return nil
} }
@@ -442,9 +440,6 @@ func (cli *Instance) GetSecretWithVersion(
secret.Debug("GetSecretWithVersion called", secret.Debug("GetSecretWithVersion called",
"secretName", secretName, "version", version) "secretName", secretName, "version", version)
// Store the command for output
cli.cmd = cmd
// Get current vault // Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil { if err != nil {
@@ -460,22 +455,15 @@ func (cli *Instance) GetSecretWithVersion(
return err return err
} }
defer value.Destroy()
secret.Debug("Got secret value", "valueLength", len(value)) secret.Debug("Got secret value", "valueLength", value.Size())
// Print the secret value to stdout // Write the value straight from locked memory, with no trailing newline
_, _ = cli.Print(string(value)) _, _ = cmd.OutOrStdout().Write(value.Bytes())
secret.Debug("Printed value to stdout") secret.Debug("Printed value to stdout")
// Debug: Log what we're actually printing
secret.Debug("Secret retrieval debug info",
"secretName", secretName,
"version", version,
"valueLength", len(value),
"valueAsString", string(value),
"isEmpty", len(value) == 0)
return nil return nil
} }
+18 -4
View File
@@ -143,7 +143,10 @@ func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
// Verify the secret was stored correctly // Verify the secret was stored correctly
retrievedValue, err := vlt.GetSecret(secretName) retrievedValue, err := vlt.GetSecret(secretName)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, testData, retrievedValue,
defer retrievedValue.Destroy()
assert.Equal(t, testData, retrievedValue.Bytes(),
"Retrieved secret should match original (without newline)") "Retrieved secret should match original (without newline)")
} }
@@ -193,7 +196,11 @@ func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string
// Verify the secret was stored correctly // Verify the secret was stored correctly
retrievedValue, err := vlt.GetSecret(secretName) retrievedValue, err := vlt.GetSecret(secretName)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original")
defer retrievedValue.Destroy()
assert.Equal(t, testData, retrievedValue.Bytes(),
"Retrieved secret should match original")
} }
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin // TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
@@ -375,7 +382,10 @@ func TestAddSecretBufferGrowth(t *testing.T) {
// Verify the secret was stored correctly // Verify the secret was stored correctly
retrievedValue, err := vlt.GetSecret(secretName) retrievedValue, err := vlt.GetSecret(secretName)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, testData, retrievedValue,
defer retrievedValue.Destroy()
assert.Equal(t, testData, retrievedValue.Bytes(),
"Retrieved secret should match original exactly") "Retrieved secret should match original exactly")
}) })
} }
@@ -416,7 +426,11 @@ func TestAddSecretStreamingBehavior(t *testing.T) {
// Verify the secret was stored correctly // Verify the secret was stored correctly
retrievedValue, err := vlt.GetSecret("streaming-test") retrievedValue, err := vlt.GetSecret("streaming-test")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original")
defer retrievedValue.Destroy()
assert.Equal(t, testData, retrievedValue.Bytes(),
"Retrieved secret should match original")
} }
// slowReader simulates a reader that returns data in small chunks // slowReader simulates a reader that returns data in small chunks
+44 -1
View File
@@ -744,14 +744,43 @@ func (cli *Instance) removeUnlocker(
return err return err
} }
// Get list of unlockers // Get list of unlockers. It leaves out a directory whose metadata file
// is missing or cannot be checked for, read or parsed.
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)
} }
vaultDir, err := vlt.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get vault directory: %w", err)
}
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
// 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 {
lastID, err := findUnlockerIDByMetadata(
cli.fs, unlockersDir, unlockers[0], true)
if err != nil {
return err
}
removingLast = lastID == unlockerID
}
// unlockerID may instead name a directory left out of the list. If its
// metadata file is missing or corrupt it is not a working unlocker, so
// removing it never removes the last one. If the file cannot be checked
// for or read, the unlocker may be the only working one, so removing it
// counts as removing the last unlocker.
if metadataUnreadable(cli.fs, filepath.Join(unlockersDir, unlockerID)) {
removingLast = true
}
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 {
@@ -785,6 +814,20 @@ func (cli *Instance) removeUnlocker(
return nil return nil
} }
// metadataUnreadable reports whether checking for or reading the metadata
// file in the unlocker directory unlockerDir fails. A missing file is not
// a failure.
func metadataUnreadable(fs afero.Fs, unlockerDir string) bool {
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
exists, err := afero.Exists(fs, metadataPath)
if err == nil && exists {
_, err = afero.ReadFile(fs, metadataPath)
}
return err != nil
}
// UnlockerSelect selects an unlocker as current // UnlockerSelect selects an unlocker as current
func (cli *Instance) UnlockerSelect(unlockerID string) error { func (cli *Instance) UnlockerSelect(unlockerID string) error {
release, err := vault.LockStateDir(cli.fs, cli.stateDir) release, err := vault.LockStateDir(cli.fs, cli.stateDir)
+167
View File
@@ -0,0 +1,167 @@
// 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. A
// last test checks that an unlocker whose metadata file cannot be read is
// removed by its directory name only as the last unlocker is.
//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...)
})
}
}
// TestUnlockerRemoveWithUnreadableMetadata asserts that removing the only
// unlocker of a vault with secrets by its directory name, when its
// metadata file cannot be checked for or read, is refused without --force:
// listing leaves it out, but it may still be the vault's only working
// unlocker. With --force it is removed. The state directory lock refuses
// the failing filesystem, so the test calls removeUnlocker, which
// UnlockersRemove runs once it holds the lock.
func TestUnlockerRemoveWithUnreadableMetadata(t *testing.T) {
t.Parallel()
vaultDir := testVaultDir(listTestVaultName)
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
failingPath := filepath.Join(unlockersDir, listTestUnlockerDirOne,
listTestMetadataFileName)
tests := []struct {
name string
wrap func(base afero.Fs) afero.Fs
}{
{
name: "checking for the file fails",
wrap: func(base afero.Fs) afero.Fs {
return &metadataStatFailFs{Fs: base, uncheckablePath: failingPath}
},
},
{
name: "reading the file fails",
wrap: func(base afero.Fs) afero.Fs {
return &metadataReadFailFs{Fs: base, unreadablePath: failingPath}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 1)
writeTestSecret(t, base, vaultDir)
instance, cmd := newTestInstance(tt.wrap(base))
err := instance.removeUnlocker(listTestUnlockerDirOne, false, cmd)
require.ErrorIs(t, err, errLastUnlocker)
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
require.NoError(t,
instance.removeUnlocker(listTestUnlockerDirOne, true, cmd))
assertDirEntries(t, base, unlockersDir)
})
}
}
+46
View File
@@ -0,0 +1,46 @@
package cli_test
import (
"strings"
"testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// usageHeading starts the usage text cobra prints after an error.
const usageHeading = "Usage:"
// A command called wrongly gets usage after its error; a command that
// fails while running gets its error alone. Either way the command fails
// and its error is shown exactly once.
//
//nolint:paralleltest // executes the CLI in-process and sets the environment
func TestUsageOnlyForCallErrors(t *testing.T) {
// No vault in the state directory, so `get x` fails while running.
env := map[string]string{secret.EnvStateDir: t.TempDir()}
tests := []struct {
call string
wantUsage bool
}{
{call: "get", wantUsage: true},
{call: "get x y", wantUsage: true},
{call: "get --no-such-flag x", wantUsage: true},
{call: "generate secret x --length abc", wantUsage: true},
{call: "import x", wantUsage: true},
{call: "get x", wantUsage: false},
}
for _, tt := range tests {
output, err := cli.ExecuteCommandInProcess(strings.Fields(tt.call), "", env)
require.Error(t, err, "%q should fail", tt.call)
assert.Equal(t, 1, strings.Count(output, err.Error()),
"%q should show its error once:\n%s", tt.call, output)
assert.Equal(t, tt.wantUsage, strings.Contains(output, usageHeading),
"usage shown for %q:\n%s", tt.call, output)
}
}
+51 -3
View File
@@ -7,6 +7,7 @@
// - TestPromoteVersionCommand: Tests `secret version promote` command // - TestPromoteVersionCommand: Tests `secret version promote` command
// - TestPromoteNonExistentVersion: Tests error handling for invalid promotion // - TestPromoteNonExistentVersion: Tests error handling for invalid promotion
// - TestGetSecretWithVersion: Tests `secret get --version` flag functionality // - TestGetSecretWithVersion: Tests `secret get --version` flag functionality
// - TestGetSecretWritesBinaryValue: Tests `secret get` output of binary values
// - TestVersionCommandStructure: Tests command structure and help text // - TestVersionCommandStructure: Tests command structure and help text
// - TestListVersionsEmptyOutput: Tests edge case with no versions // - TestListVersionsEmptyOutput: Tests edge case with no versions
// //
@@ -23,6 +24,7 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"unicode/utf8"
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
@@ -188,7 +190,10 @@ func TestPromoteVersionCommand(t *testing.T) {
// Current should be version-2 // Current should be version-2
value, err := vlt.GetSecret("test/secret") value, err := vlt.GetSecret("test/secret")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []byte("version-2"), value)
defer value.Destroy()
assert.Equal(t, []byte("version-2"), value.Bytes())
// Promote first version // Promote first version
firstVersion := versions[1] // Older version firstVersion := versions[1] // Older version
@@ -211,9 +216,12 @@ func TestPromoteVersionCommand(t *testing.T) {
assert.Contains(t, outputStr, firstVersion) assert.Contains(t, outputStr, firstVersion)
// Verify current is now version-1 // Verify current is now version-1
value, err = vlt.GetSecret("test/secret") promoted, err := vlt.GetSecret("test/secret")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []byte("version-1"), value)
defer promoted.Destroy()
assert.Equal(t, []byte("version-1"), promoted.Bytes())
} }
//nolint:paralleltest // uses t.Setenv via setupTestVault //nolint:paralleltest // uses t.Setenv via setupTestVault
@@ -290,6 +298,46 @@ func TestGetSecretWithVersion(t *testing.T) {
assert.Equal(t, "version-1", buf.String()) assert.Equal(t, "version-1", buf.String())
} }
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestGetSecretWritesBinaryValue(t *testing.T) {
fs := afero.NewMemMapFs()
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
setupTestVault(t, fs)
vlt, err := vault.GetCurrentVault(fs, testStateDir)
require.NoError(t, err)
value := []byte{0x00, 'a', 0xff, 0xfe, 0x00, 0xc3, 0x28, 'z', 0x00}
require.False(t, utf8.Valid(value))
// A copy, since storing a value wipes the slice it came from
addTestSecret(t, vlt, bytes.Clone(value), false)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
versions, err := secret.ListVersions(fs,
filepath.Join(vaultDir, "secrets.d", "test%secret"))
require.NoError(t, err)
require.Len(t, versions, 1)
cmd := newRootCmd()
var buf bytes.Buffer
cmd.SetOut(&buf)
// Each writes exactly the stored bytes, with no trailing newline
err = cli.GetSecret(cmd, "test/secret")
require.NoError(t, err)
assert.Equal(t, value, buf.Bytes())
buf.Reset()
err = cli.GetSecretWithVersion(cmd, "test/secret", versions[0])
require.NoError(t, err)
assert.Equal(t, value, buf.Bytes())
}
//nolint:paralleltest // reads process environment to determine the state dir //nolint:paralleltest // reads process environment to determine the state dir
func TestVersionCommandStructure(t *testing.T) { func TestVersionCommandStructure(t *testing.T) {
// Test that version commands are properly structured // Test that version commands are properly structured
+8 -2
View File
@@ -342,7 +342,10 @@ func TestLongestNames(t *testing.T) {
got, err := vlt.GetSecret(name) got, err := vlt.GetSecret(name)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "long", string(got))
defer got.Destroy()
assert.Equal(t, []byte("long"), got.Bytes())
vaultDir, err := vlt.GetDirectory() vaultDir, err := vlt.GetDirectory()
require.NoError(t, err) require.NoError(t, err)
@@ -380,7 +383,10 @@ func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
value, err := dest.GetSecret("shared") value, err := dest.GetSecret("shared")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "old", string(value))
defer value.Destroy()
assert.Equal(t, []byte("old"), value.Bytes())
}) })
} }
} }
+4 -2
View File
@@ -1,6 +1,7 @@
package vault_test package vault_test
import ( import (
"bytes"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
@@ -197,10 +198,11 @@ func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
if err != nil { if err != nil {
t.Fatalf("Failed to retrieve deep path secret: %v", err) t.Fatalf("Failed to retrieve deep path secret: %v", err)
} }
defer retrievedValue.Destroy()
if string(retrievedValue) != string(expectedValue) { if !bytes.Equal(retrievedValue.Bytes(), expectedValue) {
t.Errorf("Retrieved value doesn't match. Expected %q, got %q", t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
string(expectedValue), string(retrievedValue)) expectedValue, retrievedValue.Bytes())
} }
} }
+35 -9
View File
@@ -119,7 +119,10 @@ func testCreateInitialVersion(
// Verify secret can be retrieved // Verify secret can be retrieved
value, err := vault.GetSecret(secretName) value, err := vault.GetSecret(secretName)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []byte("version-1-data"), value)
defer value.Destroy()
assert.Equal(t, []byte("version-1-data"), value.Bytes())
// Verify version directory structure // Verify version directory structure
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test") secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
@@ -166,7 +169,10 @@ func testCreateSecondVersion(
// Verify new value is current // Verify new value is current
value, err := vault.GetSecret(secretName) value, err := vault.GetSecret(secretName)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []byte("version-2-data"), value)
defer value.Destroy()
assert.Equal(t, []byte("version-2-data"), value.Bytes())
// Verify we now have two versions // Verify we now have two versions
versions, err = secret.ListVersions(fs, secretDir) versions, err = secret.ListVersions(fs, secretDir)
@@ -209,7 +215,10 @@ func testCreateThirdVersion(
// Current should be version-3 // Current should be version-3
value, err := vault.GetSecret(secretName) value, err := vault.GetSecret(secretName)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []byte("version-3-data"), value)
defer value.Destroy()
assert.Equal(t, []byte("version-3-data"), value.Bytes())
} }
func testRetrieveSpecificVersions( func testRetrieveSpecificVersions(
@@ -225,15 +234,24 @@ func testRetrieveSpecificVersions(
// Get each version by its name // Get each version by its name
value1, err := vault.GetSecretVersion(secretName, versions[2]) // oldest value1, err := vault.GetSecretVersion(secretName, versions[2]) // oldest
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []byte("version-1-data"), value1)
defer value1.Destroy()
assert.Equal(t, []byte("version-1-data"), value1.Bytes())
value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []byte("version-2-data"), value2)
defer value2.Destroy()
assert.Equal(t, []byte("version-2-data"), value2.Bytes())
value3, err := vault.GetSecretVersion(secretName, versions[0]) // newest value3, err := vault.GetSecretVersion(secretName, versions[0]) // newest
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []byte("version-3-data"), value3)
defer value3.Destroy()
assert.Equal(t, []byte("version-3-data"), value3.Bytes())
// An empty version is not one of the versions; GetSecret gets the // An empty version is not one of the versions; GetSecret gets the
// current one // current one
@@ -259,7 +277,10 @@ func testPromoteOldVersion(
// Verify current now returns the old version's value // Verify current now returns the old version's value
value, err := vault.GetSecret(secretName) value, err := vault.GetSecret(secretName)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []byte("version-1-data"), value)
defer value.Destroy()
assert.Equal(t, []byte("version-1-data"), value.Bytes())
// Verify the version metadata hasn't changed // Verify the version metadata hasn't changed
// (promoting shouldn't modify timestamps) // (promoting shouldn't modify timestamps)
@@ -353,8 +374,13 @@ func TestVersionConcurrency(t *testing.T) {
value, err := vault.GetSecret(secretName) value, err := vault.GetSecret(secretName)
if err != nil { if err != nil {
errCh <- err errCh <- err
} else if string(value) != "initial" { } else {
errCh <- fmt.Errorf("%w: %s", errUnexpectedValue, value) if value.String() != "initial" {
errCh <- fmt.Errorf("%w: %s",
errUnexpectedValue, value.Bytes())
}
value.Destroy()
} }
done <- true done <- true
+9 -17
View File
@@ -301,8 +301,9 @@ func updateVersionMetadata(
return nil return nil
} }
// GetSecret retrieves the current version of a secret from this vault // GetSecret retrieves the current version of a secret from this vault.
func (v *Vault) GetSecret(name string) ([]byte, error) { // The caller must destroy the returned buffer.
func (v *Vault) GetSecret(name string) (*memguard.LockedBuffer, error) {
secret.DebugWith("Getting secret from vault", secret.DebugWith("Getting secret from vault",
slog.String("vault_name", v.Name), slog.String("vault_name", v.Name),
slog.String("secret_name", name), slog.String("secret_name", name),
@@ -326,7 +327,10 @@ func (v *Vault) GetSecret(name string) ([]byte, error) {
// GetSecretVersion retrieves a specific version of a secret. The version // GetSecretVersion retrieves a specific version of a secret. The version
// must be one of the secret's versions; GetSecret gets the current one. // must be one of the secret's versions; GetSecret gets the current one.
func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) { // The caller must destroy the returned buffer.
func (v *Vault) GetSecretVersion(
name string, version string,
) (*memguard.LockedBuffer, error) {
secret.DebugWith("Getting secret version from vault", secret.DebugWith("Getting secret version from vault",
slog.String("vault_name", v.Name), slog.String("vault_name", v.Name),
slog.String("secret_name", name), slog.String("secret_name", name),
@@ -372,26 +376,14 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
return nil, fmt.Errorf("failed to decrypt version: %w", err) return nil, fmt.Errorf("failed to decrypt version: %w", err)
} }
// Create a copy to return since the buffer will be destroyed
result := make([]byte, decryptedValue.Size())
copy(result, decryptedValue.Bytes())
decryptedValue.Destroy()
secret.DebugWith("Successfully decrypted secret version", secret.DebugWith("Successfully decrypted secret version",
slog.String("secret_name", name), slog.String("secret_name", name),
slog.String("version", version), slog.String("version", version),
slog.String("vault_name", v.Name), slog.String("vault_name", v.Name),
slog.Int("decrypted_length", len(result)), slog.Int("decrypted_length", decryptedValue.Size()),
) )
// Debug: Log metadata about the decrypted value without exposing the actual secret return decryptedValue, nil
secret.Debug("Vault secret decryption debug info",
"secret_name", name,
"version", version,
"decrypted_value_length", len(result),
"is_empty", len(result) == 0)
return result, nil
} }
// UnlockVault unlocks the vault and returns the long-term private key // UnlockVault unlocks the vault and returns the long-term private key
+18 -6
View File
@@ -131,7 +131,10 @@ func TestVaultAddSecretCreatesVersion(t *testing.T) {
// Get the secret value // Get the secret value
retrievedValue, err := vault.GetSecret(testSecretPath) retrievedValue, err := vault.GetSecret(testSecretPath)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, expectedValue, retrievedValue)
defer retrievedValue.Destroy()
assert.Equal(t, expectedValue, retrievedValue.Bytes())
} }
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv //nolint:paralleltest // createTestVaultWithKey uses t.Setenv
@@ -165,7 +168,10 @@ func TestVaultAddSecretMultipleVersions(t *testing.T) {
// Current value should be version-2 // Current value should be version-2
value, err := vault.GetSecret(testSecretPath) value, err := vault.GetSecret(testSecretPath)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []byte("version-2"), value)
defer value.Destroy()
assert.Equal(t, []byte("version-2"), value.Bytes())
} }
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv //nolint:paralleltest // createTestVaultWithKey uses t.Setenv
@@ -192,15 +198,21 @@ func TestVaultGetSecretVersion(t *testing.T) {
// Get specific version (first one) // Get specific version (first one)
firstVersion := versions[1] // Last in list is first created firstVersion := versions[1] // Last in list is first created
value, err := vault.GetSecretVersion(testSecretPath, firstVersion) first, err := vault.GetSecretVersion(testSecretPath, firstVersion)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []byte("version-1"), value)
defer first.Destroy()
assert.Equal(t, []byte("version-1"), first.Bytes())
// Get specific version (second one) // Get specific version (second one)
secondVersion := versions[0] // First in list is most recent secondVersion := versions[0] // First in list is most recent
value, err = vault.GetSecretVersion(testSecretPath, secondVersion) second, err := vault.GetSecretVersion(testSecretPath, secondVersion)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []byte("version-2"), value)
defer second.Destroy()
assert.Equal(t, []byte("version-2"), second.Bytes())
// An empty version is not one of the versions; GetSecret gets the // An empty version is not one of the versions; GetSecret gets the
// current one // current one
+74 -67
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
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 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)
} }
+4 -2
View File
@@ -1,6 +1,7 @@
package vault_test package vault_test
import ( import (
"bytes"
"path/filepath" "path/filepath"
"slices" "slices"
"testing" "testing"
@@ -184,10 +185,11 @@ func testSecretOperations(t *testing.T, fs afero.Fs) {
if err != nil { if err != nil {
t.Fatalf("Failed to get secret: %v", err) t.Fatalf("Failed to get secret: %v", err)
} }
defer retrievedValue.Destroy()
if string(retrievedValue) != string(expectedValue) { if !bytes.Equal(retrievedValue.Bytes(), expectedValue) {
t.Errorf("Expected secret value '%s', got '%s'", t.Errorf("Expected secret value '%s', got '%s'",
string(expectedValue), string(retrievedValue)) expectedValue, retrievedValue.Bytes())
} }
} }