Compare commits

1 Commits
Author SHA1 Message Date
sneak 974b1b6dc5 Stop secret mv deleting a secret moved onto itself (closes #73)
check / check (push) Successful in 1m32s
`secret mv --force x x` deleted the secret: a move within one vault
removes an existing destination before renaming the source onto it. The
same happened for `work:x work:`, `work:x work` and `work:x ""`, where an
empty destination defaults to the source name.

moveSecretWithinVault now rejects a move whose two names are the same
before touching anything. A move within a named vault works in that
vault directly instead of selecting it, so the current vault never
changes; the vault must be one of the existing vaults.

The test runs each rejected move on a copy of two in-memory vaults and
requires the exact error and an unchanged state directory.

Model: opus-5-5
2026-10-03 23:47:57 +00:00
15 changed files with 134 additions and 398 deletions

No files matched your search

+2 -19
View File
@@ -29,25 +29,8 @@ Bring the repo into policy compliance in one commit:
source (`mv --force x x`, `mv --force work:x work:`, or an empty source (`mv --force x x`, `mv --force work:x work:`, or an empty
destination, which defaults to the source name) before changing destination, which defaults to the source name) before changing
anything; before, `--force` removed the destination first and so anything; before, `--force` removed the destination first and so
deleted the secret. Every vault name given with `vault:` must be one deleted the secret. A move within a named vault no longer makes that
of the existing vaults by exact name, so `work:x work/:x` is rejected vault the current one, whether it succeeds or fails.
instead of being taken for a move between two vaults. A move within a
named vault no longer makes that vault the current one, whether it
succeeds or fails.
- 2026-10-03: `version rm`, `version promote` and `get --version`
accept a version only if it is one of the versions `version list`
lists for that secret, compared as typed before any path is built
(`secret.VersionExists`), and touch nothing otherwise. An empty
`--version` is rejected instead of meaning the current version.
Before, `secret version rm x ../../..` deleted the whole vault,
`secret version rm x ..` the secret, and `.` or `""` every version.
- 2026-10-03: Key material is wiped on every exit: `Entry()` returns
the exit code after its deferred `memguard.Purge()` has run, and only
`main` calls `os.Exit`. SIGINT and SIGTERM go through memguard's
handler, which wipes every buffer before exiting; when the process is
in the terminal's foreground process group it first restores the
terminal settings from startup, so an interrupted passphrase prompt no
longer leaves echo off.
- 2026-10-03: Every command that builds a path from a secret name - 2026-10-03: Every command that builds a path from a secret name
checks the name first with `vault.ValidateSecretName` and touches checks the name first with `vault.ValidateSecretName` and touches
nothing when it is invalid: `rm`, `mv` (both names, within a vault nothing when it is invalid: `rm`, `mv` (both names, within a vault
+2 -6
View File
@@ -1,12 +1,8 @@
// Package main is the entry point for the secret CLI application. // Package main is the entry point for the secret CLI application.
package main package main
import ( import "git.eeqj.de/sneak/secret/internal/cli"
"os"
"git.eeqj.de/sneak/secret/internal/cli"
)
func main() { func main() {
os.Exit(cli.Entry()) cli.Entry()
} }
-108
View File
@@ -1,108 +0,0 @@
package cli_test
import (
"bufio"
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Entry must return its exit code rather than exit, so that its deferred
// memguard purge runs on the success and the error path alike.
//
//nolint:paralleltest // sets os.Args, and Entry wipes every buffer in the process
func TestEntryWipesBuffersAndReturnsExitCode(t *testing.T) {
savedArgs := os.Args
t.Cleanup(func() { os.Args = savedArgs })
tests := []struct {
args []string
exitCode int
}{
{args: []string{"secret", "--help"}, exitCode: 0},
{args: []string{"secret", "no-such-command"}, exitCode: 1},
}
for _, tt := range tests {
buf := memguard.NewBufferFromBytes([]byte("key material"))
os.Args = tt.args
assert.Equal(t, tt.exitCode, cli.Entry(), "exit code for %v", tt.args)
assert.False(t, buf.IsAlive(), "Entry left a buffer unwiped for %v", tt.args)
}
}
// Ctrl-C while `secret add` waits for the value on stdin must end the
// process through memguard's signal handler, which wipes every buffer and
// exits with status 1, not through Go's default handling, which kills the
// process with the buffers intact.
func TestInterruptExitsThroughMemguard(t *testing.T) {
t.Parallel()
const waitingForValue = "Reading secret value from stdin"
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
wd, err := filepath.Abs("../..")
require.NoError(t, err)
secretPath := filepath.Join(wd, "secret")
env := []string{
secret.EnvStateDir + "=" + t.TempDir(),
secret.EnvMnemonic + "=" + testMnemonic,
secret.EnvUnlockPassphrase + "=test-passphrase",
"PATH=/usr/bin:/bin",
// The debug log on stderr shows when add starts waiting for the value.
"GODEBUG=berlin.sneak.pkg.secret",
}
//nolint:gosec // G204: test executes the freshly built secret binary
initCmd := exec.CommandContext(ctx, secretPath, "init")
initCmd.Env = env
output, err := initCmd.CombinedOutput()
require.NoError(t, err, "init should succeed: %s", output)
//nolint:gosec // G204: test executes the freshly built secret binary
addCmd := exec.CommandContext(ctx, secretPath, "add", "test/secret")
addCmd.Env = env
// Held open and never written, so add keeps waiting for the value.
stdin, err := addCmd.StdinPipe()
require.NoError(t, err)
defer func() { _ = stdin.Close() }()
stderr, err := addCmd.StderrPipe()
require.NoError(t, err)
require.NoError(t, addCmd.Start())
waiting := false
scanner := bufio.NewScanner(stderr)
for !waiting && scanner.Scan() {
waiting = strings.Contains(scanner.Text(), waitingForValue)
}
require.True(t, waiting, "add never logged %q", waitingForValue)
require.NoError(t, addCmd.Process.Signal(os.Interrupt))
err = addCmd.Wait()
var exitErr *exec.ExitError
require.ErrorAs(t, err, &exitErr)
assert.Equal(t, 1, exitErr.ExitCode(), "add ended with %v", err)
}
-8
View File
@@ -829,14 +829,6 @@ func test09GetSpecificVersion(t *testing.T, tempDir, testMnemonic string, runSec
require.NoError(t, err, "get current version should succeed") require.NoError(t, err, "get current version should succeed")
assert.Equal(t, "newpassword456", strings.TrimSpace(output), "should return new secret value without --version") assert.Equal(t, "newpassword456", strings.TrimSpace(output), "should return new secret value without --version")
// An empty --version is not a version; it does not mean the current one
output, err = runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic,
}, "get", "--version", "", "database/password")
require.Error(t, err, "get with an empty version should fail")
assert.Contains(t, output, "version '' not found", "should reject the empty version")
} }
func test10PromoteVersion(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) { func test10PromoteVersion(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
+3 -19
View File
@@ -10,10 +10,9 @@ import (
// TestRejectedMoveWithinVaultLeavesStateUnchanged is a regression test for // TestRejectedMoveWithinVaultLeavesStateUnchanged is a regression test for
// https://git.eeqj.de/sneak/secret/issues/73, where a forced move of a secret // https://git.eeqj.de/sneak/secret/issues/73, where a forced move of a secret
// onto itself deleted it, also when "work" was spelled two ways, and a failed // onto itself deleted it, and a failed move within "work" left "work" the
// move within "work" left "work" the current vault. "default" is the current // current vault. "default" is the current vault in every case, and each case
// vault in every case, and each case runs on its own copy of the state // runs on its own copy of the state directory.
// directory.
// //
//nolint:paralleltest // newTwoVaultFs uses t.Setenv //nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) { func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
@@ -48,21 +47,6 @@ func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
"mv --force ..:x ..:y", "..:x", "..:y", true, "mv --force ..:x ..:y", "..:x", "..:y", true,
"vault '..' does not exist", "vault '..' does not exist",
}, },
// Each of these spells "work" a second way. The spelling is not an
// existing vault name, so the move is not taken for a move between
// two vaults, which would delete the destination, here the source.
{
"mv --force work:x work/:x", workX, "work/:x", true,
"vault 'work/' does not exist",
},
{
"mv --force work/:x work:", "work/:x", "work:", true,
"vault 'work/' does not exist",
},
{
"mv --force work:x ./work:x", workX, "./work:x", true,
"vault './work' does not exist",
},
} }
for _, tt := range tests { for _, tt := range tests {
+9 -90
View File
@@ -1,7 +1,6 @@
package cli_test package cli_test
import ( import (
"fmt"
"maps" "maps"
"os" "os"
"slices" "slices"
@@ -120,13 +119,13 @@ func newFsFromSnapshot(t *testing.T, tree map[string]string) afero.Fs {
} }
// requireRejectedAndUnchanged runs a command on a copy of the state // requireRejectedAndUnchanged runs a command on a copy of the state
// directory recorded in before. It requires an error with exactly the // directory recorded in before. It requires exactly the error
// message of want, so that a later check rejecting the argument does not // vault.ValidateSecretName gives for the rejected name, so that a later
// count, and everything under the state directory as it was: the error // check rejecting the name does not count, and everything under the state
// alone proves nothing, since it could come after the vault had already // directory as it was: the error alone proves nothing, since it could come
// been deleted. // after the vault had already been deleted.
func requireRejectedAndUnchanged( func requireRejectedAndUnchanged(
t *testing.T, before map[string]string, want error, t *testing.T, before map[string]string, rejected string,
run func(c *cli.Instance) error, run func(c *cli.Instance) error,
) { ) {
t.Helper() t.Helper()
@@ -136,7 +135,8 @@ func requireRejectedAndUnchanged(
err := run(cli.NewCLIInstanceWithStateDir(fs, testStateDir)) err := run(cli.NewCLIInstanceWithStateDir(fs, testStateDir))
require.Equal(t, before, snapshotStateDir(t, fs)) require.Equal(t, before, snapshotStateDir(t, fs))
require.EqualError(t, err, want.Error()) require.ErrorIs(t, err, vault.ErrInvalidSecretName)
require.EqualError(t, err, vault.ValidateSecretName(rejected).Error())
} }
// TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for // TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for
@@ -229,92 +229,11 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) { t.Run(tt.command, func(t *testing.T) {
requireRejectedAndUnchanged(t, before, vault.ValidateSecretName(tt.rejected), tt.run) requireRejectedAndUnchanged(t, before, tt.rejected, tt.run)
}) })
} }
} }
// TestInvalidVersionLeavesVaultsUnchanged is a regression test for
// https://git.eeqj.de/sneak/secret/issues/67, where
// `secret version rm x ../../..` deleted the whole vault,
// `secret version rm x ..` the secret x, and `secret version rm x .` or
// `secret version rm x ""` every version of x. A version argument is
// accepted only if it is one of the versions `secret version list` lists.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
before := snapshotStateDir(t, newTwoVaultFs(t))
cmd := &cobra.Command{}
commands := []struct {
command string
run func(c *cli.Instance, version string) error
}{
{"version rm x", func(c *cli.Instance, version string) error {
return c.RemoveVersion(cmd, "x", version)
}},
{"version promote x", func(c *cli.Instance, version string) error {
return c.PromoteVersion(cmd, "x", version)
}},
{"get x --version", func(c *cli.Instance, version string) error {
return c.GetSecretWithVersion(cmd, "x", version)
}},
}
for _, tt := range commands {
for _, version := range []string{"", ".", "..", "../../..", "a/b"} {
t.Run(fmt.Sprintf("%s %q", tt.command, version), func(t *testing.T) {
want := fmt.Errorf("version '%s' %w '%s'",
version, vault.ErrVersionNotFound, "x")
requireRejectedAndUnchanged(t, before, want,
func(c *cli.Instance) error { return tt.run(c, version) })
})
}
}
}
// TestRemoveVersionRemovesOnlyThatVersion checks that `secret version rm`
// with a version that is not the current one removes that version and
// changes nothing else.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
fs := newTwoVaultFs(t)
vlt, err := vault.GetCurrentVault(fs, testStateDir)
require.NoError(t, err)
// A second version of "x" becomes the current one.
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("new")), true)
require.NoError(t, err)
secretDir := testStateDir + "/vaults.d/default/secrets.d/x"
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 2)
// ListVersions lists the newest version first.
oldDir := secretDir + "/versions/" + versions[1] + "/"
before := snapshotStateDir(t, fs)
require.Contains(t, before, oldDir)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
err = c.RemoveVersion(&cobra.Command{}, "x", versions[1])
require.NoError(t, err)
// Expected: the state as before without everything under oldDir.
want := map[string]string{}
for path, content := range before {
if !strings.HasPrefix(path, oldDir) {
want[path] = content
}
}
require.Equal(t, want, snapshotStateDir(t, fs))
}
// TestMoveToVaultNameRenamesInCurrentVault checks that `secret mv x work`, // TestMoveToVaultNameRenamesInCurrentVault checks that `secret mv x work`,
// where "work" is also the name of a vault, renames the secret "x" to "work" // where "work" is also the name of a vault, renames the secret "x" to "work"
// in the current vault and changes nothing else. // in the current vault and changes nothing else.
+5 -26
View File
@@ -4,38 +4,17 @@ import (
"os" "os"
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/sys/unix"
"golang.org/x/term"
) )
// Entry runs the secret CLI and returns the process exit code. It wipes // Entry is the entry point for the secret CLI application
// every memguard buffer before it returns, so the caller must do nothing func Entry() {
// but exit with the code. cmd := newRootCmd()
func Entry() int {
// On SIGINT or SIGTERM memguard runs this function, wipes every buffer
// and exits with status 1. The passphrase prompt turns terminal echo
// off until the read finishes, so a signal there would leave echo off.
// Only a process in the terminal's foreground process group may reset
// it: one in the background that tries is stopped instead of exiting.
terminalState, terminalErr := term.GetState(unix.Stdin)
memguard.CatchSignal(func(os.Signal) { err := cmd.Execute()
foreground, err := unix.IoctlGetInt(unix.Stdin, unix.TIOCGPGRP)
if terminalErr == nil && err == nil && foreground == unix.Getpgrp() {
_ = term.Restore(unix.Stdin, terminalState)
}
}, os.Interrupt, unix.SIGTERM)
defer memguard.Purge()
err := newRootCmd().Execute()
if err != nil { if err != nil {
return 1 os.Exit(1)
} }
return 0
} }
func newRootCmd() *cobra.Command { func newRootCmd() *cobra.Command {
+59 -64
View File
@@ -110,12 +110,6 @@ func newGetCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err) return fmt.Errorf("failed to initialize CLI: %w", err)
} }
// Without --version, get the current version. A given
// --version is checked as typed, so an empty one is rejected.
if !cmd.Flags().Changed("version") {
return cli.GetSecret(cmd, args[0])
}
return cli.GetSecretWithVersion(cmd, args[0], version) return cli.GetSecretWithVersion(cmd, args[0], version)
}, },
} }
@@ -400,32 +394,12 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
return nil return nil
} }
// GetSecret retrieves and prints the current version of a secret // GetSecret retrieves and prints a secret from the current vault
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) return cli.GetSecretWithVersion(cmd, secretName, "")
// Store the command for output
cli.cmd = cmd
// Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return err
}
value, err := vlt.GetSecret(secretName)
if err != nil {
return err
}
// Print the secret value to stdout
_, _ = cli.Print(string(value))
return nil
} }
// GetSecretWithVersion retrieves and prints a specific version of a secret. // GetSecretWithVersion retrieves and prints a specific version of a secret
// The version must be one of the secret's versions.
func (cli *Instance) GetSecretWithVersion( func (cli *Instance) GetSecretWithVersion(
cmd *cobra.Command, secretName string, version string, cmd *cobra.Command, secretName string, version string,
) error { ) error {
@@ -444,7 +418,13 @@ func (cli *Instance) GetSecretWithVersion(
} }
// Get the secret value // Get the secret value
value, err := vlt.GetSecretVersion(secretName, version) var value []byte
if version == "" {
value, err = vlt.GetSecret(secretName)
} else {
value, err = vlt.GetSecretVersion(secretName, version)
}
if err != nil { if err != nil {
secret.Debug("Failed to get secret", "error", err) secret.Debug("Failed to get secret", "error", err)
@@ -744,8 +724,8 @@ func (cli *Instance) MoveSecret(
// with a new name // with a new name
if srcQualified && !destQualified { if srcQualified && !destQualified {
// Check if dest is actually a vault name // Check if dest is actually a vault name
_, err := cli.existingVault(dest) vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
if err == nil { if err == nil && slices.Contains(vaults, dest) {
// dest is a vault name, use source secret name // dest is a vault name, use source secret name
destVaultName = dest destVaultName = dest
destSecretName = srcSecretName destSecretName = srcSecretName
@@ -773,9 +753,18 @@ func (cli *Instance) MoveSecret(
return err return err
} }
// Neither name is qualified: a rename within the current vault. // A move within one vault: the current vault when neither name is
if !srcQualified { // qualified (both vault names are then empty), else the named vault,
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) // which does not become the current vault.
if srcVaultName == destVaultName {
var vlt *vault.Vault
if srcQualified {
vlt, err = cli.existingVault(srcVaultName)
} else {
vlt, err = vault.GetCurrentVault(cli.fs, cli.stateDir)
}
if err != nil { if err != nil {
return err return err
} }
@@ -784,26 +773,9 @@ func (cli *Instance) MoveSecret(
cmd, vlt, srcSecretName, destSecretName, force) cmd, vlt, srcSecretName, destSecretName, force)
} }
// Both vaults must be existing vaults by exact name, so that two // Cross-vault move
// spellings of one vault, such as "work" and "work/", are never taken for
// two vaults. A named vault does not become the current vault.
srcVault, err := cli.existingVault(srcVaultName)
if err != nil {
return err
}
destVault, err := cli.existingVault(destVaultName)
if err != nil {
return err
}
if srcVaultName == destVaultName {
return cli.moveSecretWithinVault(
cmd, srcVault, srcSecretName, destSecretName, force)
}
return cli.moveSecretCrossVault( return cli.moveSecretCrossVault(
cmd, srcVault, srcSecretName, destVault, destSecretName, force) cmd, srcVaultName, srcSecretName, destVaultName, destSecretName, force)
} }
// existingVault returns the vault with the given name, or an error if there // existingVault returns the vault with the given name, or an error if there
@@ -878,34 +850,57 @@ func (cli *Instance) moveSecretWithinVault(
return nil return nil
} }
// moveSecretCrossVault handles moving between two different vaults. Its // moveSecretCrossVault handles moving between different vaults. Its caller,
// caller, MoveSecret, has already checked both secret names and that both // MoveSecret, has already checked both secret names.
// vaults exist.
func (cli *Instance) moveSecretCrossVault( func (cli *Instance) moveSecretCrossVault(
cmd *cobra.Command, cmd *cobra.Command,
srcVault *vault.Vault, srcSecretName string, srcVaultName, srcSecretName,
destVault *vault.Vault, destSecretName string, destVaultName, destSecretName string,
force bool, force bool,
) error { ) error {
// Get source vault
srcVault := vault.NewVault(cli.fs, cli.stateDir, srcVaultName)
srcVaultDir, err := srcVault.GetDirectory() srcVaultDir, err := srcVault.GetDirectory()
if err != nil { if err != nil {
return fmt.Errorf("failed to get source vault directory: %w", err) return fmt.Errorf("failed to get source vault directory: %w", err)
} }
// Verify source vault exists
exists, err := afero.DirExists(cli.fs, srcVaultDir)
if err != nil || !exists {
return fmt.Errorf("source vault '%s' %w", srcVaultName, errVaultDoesNotExist)
}
// Verify source secret exists // Verify source secret exists
srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%") srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%")
srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName) srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName)
exists, err := afero.DirExists(cli.fs, srcSecretDir) exists, err = afero.DirExists(cli.fs, srcSecretDir)
if err != nil || !exists { if err != nil || !exists {
return fmt.Errorf("secret '%s' %w in vault '%s'", return fmt.Errorf("secret '%s' %w in vault '%s'",
srcSecretName, errSecretNotFound, srcVault.Name) srcSecretName, errSecretNotFound, srcVaultName)
}
// Get destination vault
destVault := vault.NewVault(cli.fs, cli.stateDir, destVaultName)
destVaultDir, err := destVault.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get destination vault directory: %w", err)
}
// Verify destination vault exists
exists, err = afero.DirExists(cli.fs, destVaultDir)
if err != nil || !exists {
return fmt.Errorf("destination vault '%s' %w",
destVaultName, errVaultDoesNotExist)
} }
// Unlock destination vault (will fail if neither mnemonic nor unlocker available) // Unlock destination vault (will fail if neither mnemonic nor unlocker available)
_, err = destVault.GetOrDeriveLongTermKey() _, err = destVault.GetOrDeriveLongTermKey()
if err != nil { if err != nil {
return fmt.Errorf("failed to unlock destination vault '%s': %w", destVault.Name, err) return fmt.Errorf("failed to unlock destination vault '%s': %w", destVaultName, err)
} }
// Count versions for user feedback // Count versions for user feedback
@@ -925,13 +920,13 @@ func (cli *Instance) moveSecretCrossVault(
// Copy succeeded but delete failed - warn but don't fail // Copy succeeded but delete failed - warn but don't fail
cmd.Printf("Warning: copied secret but failed to remove source: %v\n", err) cmd.Printf("Warning: copied secret but failed to remove source: %v\n", err)
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n", cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
srcVault.Name, srcSecretName, destVault.Name, destSecretName, versionCount) srcVaultName, srcSecretName, destVaultName, destSecretName, versionCount)
return nil return nil
} }
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n", cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
srcVault.Name, srcSecretName, destVault.Name, destSecretName, versionCount) srcVaultName, srcSecretName, destVaultName, destSecretName, versionCount)
return nil return nil
} }
+6 -4
View File
@@ -265,7 +265,9 @@ func (cli *Instance) PromoteVersion(
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName) secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
// Check if version exists // Check if version exists
exists, err := secret.VersionExists(cli.fs, secretDir, version) versionDir := filepath.Join(secretDir, "versions", version)
exists, err := afero.DirExists(cli.fs, versionDir)
if err != nil { if err != nil {
return fmt.Errorf("failed to check if version exists: %w", err) return fmt.Errorf("failed to check if version exists: %w", err)
} }
@@ -321,7 +323,9 @@ func (cli *Instance) RemoveVersion(
} }
// Check if version exists // Check if version exists
exists, err = secret.VersionExists(cli.fs, secretDir, version) versionDir := filepath.Join(secretDir, "versions", version)
exists, err = afero.DirExists(cli.fs, versionDir)
if err != nil { if err != nil {
return fmt.Errorf("failed to check if version exists: %w", err) return fmt.Errorf("failed to check if version exists: %w", err)
} }
@@ -344,8 +348,6 @@ func (cli *Instance) RemoveVersion(
} }
// Remove the version directory // Remove the version directory
versionDir := filepath.Join(secretDir, "versions", version)
err = cli.fs.RemoveAll(versionDir) err = cli.fs.RemoveAll(versionDir)
if err != nil { if err != nil {
return fmt.Errorf("failed to remove version: %w", err) return fmt.Errorf("failed to remove version: %w", err)
+2 -2
View File
@@ -276,8 +276,8 @@ func TestGetSecretWithVersion(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer
cmd.SetOut(&buf) cmd.SetOut(&buf)
// Test getting the current version // Test getting current version (empty version string)
err = cli.GetSecret(cmd, "test/secret") err = cli.GetSecretWithVersion(cmd, "test/secret", "")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "version-2", buf.String()) assert.Equal(t, "version-2", buf.String())
-13
View File
@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"path/filepath" "path/filepath"
"slices"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -525,18 +524,6 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
return versions, nil return versions, nil
} }
// VersionExists reports whether version is one of the versions ListVersions
// lists for the secret in secretDir. It only compares names, so a version
// the user typed can be checked with it before any path is built from it.
func VersionExists(fs afero.Fs, secretDir string, version string) (bool, error) {
versions, err := ListVersions(fs, secretDir)
if err != nil {
return false, err
}
return slices.Contains(versions, version), nil
}
// GetCurrentVersion returns the version that the "current" file points to // GetCurrentVersion returns the version that the "current" file points to
// The file contains just the version name (e.g., "20231215.001") // The file contains just the version name (e.g., "20231215.001")
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) { func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
+1 -1
View File
@@ -49,7 +49,7 @@ var (
// ErrVersionNotFound indicates the requested secret version does not // ErrVersionNotFound indicates the requested secret version does not
// exist. Composed as // exist. Composed as
// "version '<version>' not found for secret '<name>'". // "version <version> not found for secret <name>".
ErrVersionNotFound = errors.New("not found for secret") ErrVersionNotFound = errors.New("not found for secret")
// ErrNoVersions indicates the source secret has no versions. Composed // ErrNoVersions indicates the source secret has no versions. Composed
+4 -4
View File
@@ -235,10 +235,10 @@ func testRetrieveSpecificVersions(
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []byte("version-3-data"), value3) assert.Equal(t, []byte("version-3-data"), value3)
// An empty version is not one of the versions; GetSecret gets the // Empty version should return current
// current one valueCurrent, err := vault.GetSecretVersion(secretName, "")
_, err = vault.GetSecretVersion(secretName, "") require.NoError(t, err)
require.ErrorIs(t, err, ErrVersionNotFound) assert.Equal(t, []byte("version-3-data"), valueCurrent)
} }
func testPromoteOldVersion( func testPromoteOldVersion(
+37 -30
View File
@@ -259,31 +259,18 @@ func updateVersionMetadata(
return nil return nil
} }
// GetSecret retrieves the current version of a secret from this vault // GetSecret retrieves a secret from this vault
func (v *Vault) GetSecret(name string) ([]byte, error) { func (v *Vault) GetSecret(name string) ([]byte, 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),
) )
// GetSecretObject validates the name and checks that the secret exists return v.GetSecretVersion(name, "")
secretObj, err := v.GetSecretObject(name)
if err != nil {
return nil, err
}
currentVersion, err := secret.GetCurrentVersion(v.fs, secretObj.Directory)
if err != nil {
secret.Debug("Failed to get current version", "error", err, "secret_name", name)
return nil, fmt.Errorf("failed to get current version: %w", err)
}
return v.GetSecretVersion(name, currentVersion)
} }
// GetSecretVersion retrieves a specific version of a secret. The version // GetSecretVersion retrieves a specific version of a secret (empty version
// must be one of the secret's versions; GetSecret gets the current one. // means current)
func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) { func (v *Vault) GetSecretVersion(name string, version string) ([]byte, 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),
@@ -291,8 +278,8 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
slog.String("version", version), slog.String("version", version),
) )
// Validate the name and check that the version exists // Validate the name and resolve the version to fetch
err := v.checkSecretVersion(name, version) version, err := v.resolveSecretVersion(name, version)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -653,15 +640,15 @@ func (v *Vault) updatePreviousVersion(
return nil return nil
} }
// checkSecretVersion validates the secret name and verifies that the secret // resolveSecretVersion validates the secret name, verifies the secret and
// exists and that version is one of its versions. // version exist, and resolves an empty version to the current one.
func (v *Vault) checkSecretVersion(name, version string) error { func (v *Vault) resolveSecretVersion(name, version string) (string, error) {
// Validate secret name to prevent path traversal // Validate secret name to prevent path traversal
err := ValidateSecretName(name) err := ValidateSecretName(name)
if err != nil { if err != nil {
secret.Debug("Invalid secret name provided", "secret_name", name) secret.Debug("Invalid secret name provided", "secret_name", name)
return err return "", err
} }
// Get vault directory // Get vault directory
@@ -669,7 +656,7 @@ func (v *Vault) checkSecretVersion(name, version string) error {
if err != nil { if err != nil {
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name) secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
return err return "", err
} }
// Convert slashes to percent signs for storage // Convert slashes to percent signs for storage
@@ -681,30 +668,50 @@ func (v *Vault) checkSecretVersion(name, version string) error {
if err != nil { if err != nil {
secret.Debug("Failed to check if secret exists", "error", err, "secret_name", name) secret.Debug("Failed to check if secret exists", "error", err, "secret_name", name)
return fmt.Errorf("failed to check if secret exists: %w", err) return "", fmt.Errorf("failed to check if secret exists: %w", err)
} }
if !exists { if !exists {
secret.Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name) secret.Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name)
return fmt.Errorf("secret %s %w", name, ErrSecretNotFound) return "", fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
}
// Determine which version to get
if version == "" {
// Get current version
currentVersion, err := secret.GetCurrentVersion(v.fs, secretDir)
if err != nil {
secret.Debug("Failed to get current version", "error", err, "secret_name", name)
return "", fmt.Errorf("failed to get current version: %w", err)
}
version = currentVersion
secret.Debug("Using current version", "version", version, "secret_name", name)
} }
// Check if version exists // Check if version exists
exists, err = secret.VersionExists(v.fs, secretDir, version) versionPath := filepath.Join(secretDir, "versions", version)
exists, err = afero.DirExists(v.fs, versionPath)
if err != nil { if err != nil {
secret.Debug("Failed to check if version exists", "error", err, "version", version) secret.Debug("Failed to check if version exists", "error", err, "version", version)
return fmt.Errorf("failed to check if version exists: %w", err) return "", fmt.Errorf("failed to check if version exists: %w", err)
} }
if !exists { if !exists {
secret.Debug("Version not found", "version", version, "secret_name", name) secret.Debug("Version not found", "version", version, "secret_name", name)
return fmt.Errorf("version '%s' %w '%s'", version, ErrVersionNotFound, name) return "", fmt.Errorf(
"version %s %w %s",
version, ErrVersionNotFound, name,
)
} }
return nil return version, nil
} }
// createAndSaveVersion generates a new version name, sets the version // createAndSaveVersion generates a new version name, sets the version
+4 -4
View File
@@ -202,10 +202,10 @@ func TestVaultGetSecretVersion(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []byte("version-2"), value) assert.Equal(t, []byte("version-2"), value)
// An empty version is not one of the versions; GetSecret gets the // Get current (empty version)
// current one value, err = vault.GetSecretVersion(testSecretPath, "")
_, err = vault.GetSecretVersion(testSecretPath, "") require.NoError(t, err)
require.ErrorIs(t, err, ErrVersionNotFound) assert.Equal(t, []byte("version-2"), value)
} }
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv //nolint:paralleltest // createTestVaultWithKey uses t.Setenv