Compare commits

3 Commits
Author SHA1 Message Date
sneak ef8cfb25b2 Stop secret mv deleting a secret moved onto itself (closes #73)
check / check (push) Successful in 1m28s
`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 ""`, and for
`work:x work/:x`, which named one vault two ways and so was taken for a
move between vaults.

A move whose two names are the same is now rejected before anything
changes. Every vault named with `vault:` must be one of the existing
vaults by exact name, checked before choosing between the two kinds of
move. A move within a named vault no longer makes it the current vault.

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-04 01:59:29 +00:00
clawbot bdb1c7ec18 Accept a version argument only if the secret has that version (closes #67)
check / check (push) Successful in 1m30s
version rm, version promote and get --version joined the version
argument into a path unchecked, so "", ".", "..", "../../.." removed or
read every version, the secret, the vault or directories above it.

A version is now accepted only if it is one of the versions
ListVersions lists for the secret, compared by name before any path is
built (secret.VersionExists, used by all three). An empty --version is
rejected instead of meaning the current version: GetSecretVersion no
longer treats "" as current, and GetSecret looks the current version
up itself.

Model: opus-5-5
2026-10-04 03:25:00 +02:00
clawbot 51e3731076 Wipe memguard buffers on every exit and restore echo (closes #35)
check / check (push) Successful in 1m35s
Entry() now returns the exit code and only main calls os.Exit, so the
deferred memguard.Purge() in Entry() runs on success and on error;
before, os.Exit(1) skipped every deferred Destroy().

SIGINT and SIGTERM go through memguard's handler, which wipes every
buffer and exits with status 1. The passphrase prompt turns terminal
echo off until its read returns, and the handler exits before that, so
the handler first restores the terminal settings saved at startup, but
only when this process is in the terminal's foreground process group: a
background process that changes the terminal is stopped instead of
exiting.

Model: opus-5-5
2026-10-04 02:07:58 +02:00
15 changed files with 514 additions and 140 deletions

No files matched your search

+23
View File
@@ -25,6 +25,29 @@ Bring the repo into policy compliance in one commit:
# Completed Steps # Completed Steps
- 2026-10-03: `secret mv` rejects a move whose destination is the
source (`mv --force x x`, `mv --force work:x work:`, or an empty
destination, which defaults to the source name) before changing
anything; before, `--force` removed the destination first and so
deleted the secret. Every vault name given with `vault:` must be one
of the existing vaults by exact name, so `work:x work/:x` is rejected
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
+6 -2
View File
@@ -1,8 +1,12 @@
// 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 "git.eeqj.de/sneak/secret/internal/cli" import (
"os"
"git.eeqj.de/sneak/secret/internal/cli"
)
func main() { func main() {
cli.Entry() os.Exit(cli.Entry())
} }
+108
View File
@@ -0,0 +1,108 @@
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,6 +829,14 @@ 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)) {
+99
View File
@@ -0,0 +1,99 @@
package cli_test
import (
"testing"
"git.eeqj.de/sneak/secret/internal/cli"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
)
// TestRejectedMoveWithinVaultLeavesStateUnchanged is a regression test for
// 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
// move within "work" left "work" the current vault. "default" is the current
// vault in every case, and each case runs on its own copy of the state
// directory.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
before := snapshotStateDir(t, newTwoVaultFs(t))
require.Equal(t, "default", before[testStateDir+"/currentvault"])
const (
ontoItself = "secret 'x' cannot be moved onto itself"
workX = "work:x"
)
tests := []struct {
command string
source, dest string
force bool
wantErr string
}{
{"mv x x", "x", "x", false, ontoItself},
{"mv --force x x", "x", "x", true, ontoItself},
{"mv --force work:x work:", workX, "work:", true, ontoItself},
// An empty destination name defaults to the source name.
{`mv --force work:x ""`, workX, "", true, ontoItself},
// "work" is a vault name, so the destination is work:x.
{"mv --force work:x work", workX, "work", true, ontoItself},
{
"mv work:nosuch work:y", "work:nosuch", "work:y", false,
"secret 'nosuch' not found",
},
// Only an existing vault is used, so ".." cannot reach the state
// directory itself.
{
"mv --force ..:x ..:y", "..:x", "..:y", true,
"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 {
t.Run(tt.command, func(t *testing.T) {
fs := newFsFromSnapshot(t, before)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
err := c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, tt.force)
require.Equal(t, before, snapshotStateDir(t, fs))
require.EqualError(t, err, tt.wantErr)
})
}
}
// TestMoveWithinOtherVaultKeepsCurrentVault checks that `secret mv work:x
// work:y`, with "default" the current vault, renames "x" to "y" in "work" and
// leaves "default" the current vault.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestMoveWithinOtherVaultKeepsCurrentVault(t *testing.T) {
fs := newTwoVaultFs(t)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
err := c.MoveSecret(&cobra.Command{}, "work:x", "work:y", false)
require.NoError(t, err)
after := snapshotStateDir(t, fs)
workSecrets := testStateDir + "/vaults.d/work/secrets.d/"
require.Equal(t, "default", after[testStateDir+"/currentvault"])
require.Contains(t, after, workSecrets+"y/")
require.NotContains(t, after, workSecrets+"x/")
}
+90 -9
View File
@@ -1,6 +1,7 @@
package cli_test package cli_test
import ( import (
"fmt"
"maps" "maps"
"os" "os"
"slices" "slices"
@@ -119,13 +120,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 exactly the error // directory recorded in before. It requires an error with exactly the
// vault.ValidateSecretName gives for the rejected name, so that a later // message of want, so that a later check rejecting the argument does not
// check rejecting the name does not count, and everything under the state // count, and everything under the state directory as it was: the error
// directory as it was: the error alone proves nothing, since it could come // alone proves nothing, since it could come after the vault had already
// after the vault had already been deleted. // been deleted.
func requireRejectedAndUnchanged( func requireRejectedAndUnchanged(
t *testing.T, before map[string]string, rejected string, t *testing.T, before map[string]string, want error,
run func(c *cli.Instance) error, run func(c *cli.Instance) error,
) { ) {
t.Helper() t.Helper()
@@ -135,8 +136,7 @@ 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.ErrorIs(t, err, vault.ErrInvalidSecretName) require.EqualError(t, err, want.Error())
require.EqualError(t, err, vault.ValidateSecretName(rejected).Error())
} }
// TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for // TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for
@@ -229,11 +229,92 @@ 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, tt.rejected, tt.run) requireRejectedAndUnchanged(t, before, vault.ValidateSecretName(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.
+26 -5
View File
@@ -4,17 +4,38 @@ 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 is the entry point for the secret CLI application // Entry runs the secret CLI and returns the process exit code. It wipes
func Entry() { // every memguard buffer before it returns, so the caller must do nothing
cmd := newRootCmd() // but exit with the code.
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)
err := cmd.Execute() memguard.CatchSignal(func(os.Signal) {
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 {
os.Exit(1) return 1
} }
return 0
} }
func newRootCmd() *cobra.Command { func newRootCmd() *cobra.Command {
+96 -70
View File
@@ -40,6 +40,7 @@ var (
errVaultDoesNotExist = errors.New("does not exist") errVaultDoesNotExist = errors.New("does not exist")
errCrossVaultSourceUnqualified = errors.New( errCrossVaultSourceUnqualified = errors.New(
"source must specify vault (e.g., vault:secret) for cross-vault move") "source must specify vault (e.g., vault:secret) for cross-vault move")
errMoveOntoItself = errors.New("cannot be moved onto itself")
) )
// bufferInfo tracks a protected buffer and the number of bytes used in it // bufferInfo tracks a protected buffer and the number of bytes used in it
@@ -109,6 +110,12 @@ 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)
}, },
} }
@@ -393,12 +400,32 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
return nil return nil
} }
// GetSecret retrieves and prints a secret from the current vault // GetSecret retrieves and prints the current version of a secret
func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error { func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
return cli.GetSecretWithVersion(cmd, secretName, "") secret.Debug("GetSecret called", "secretName", 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 {
@@ -417,13 +444,7 @@ func (cli *Instance) GetSecretWithVersion(
} }
// Get the secret value // Get the secret value
var value []byte value, err := vlt.GetSecretVersion(secretName, version)
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)
@@ -723,8 +744,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
vaults, err := vault.ListVaults(cli.fs, cli.stateDir) _, err := cli.existingVault(dest)
if err == nil && slices.Contains(vaults, dest) { if err == nil {
// 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
@@ -740,8 +761,8 @@ func (cli *Instance) MoveSecret(
destSecretName = srcSecretName destSecretName = srcSecretName
} }
// Check both names, for every form of the move, before selecting a vault // Check both names, for every form of the move, before building any path
// below, so that a rejected move leaves the current vault as it was. // from them.
err := vault.ValidateSecretName(srcSecretName) err := vault.ValidateSecretName(srcSecretName)
if err != nil { if err != nil {
return err return err
@@ -752,38 +773,66 @@ func (cli *Instance) MoveSecret(
return err return err
} }
// If neither is qualified, this is a simple within-vault rename // Neither name is qualified: a rename within the current vault.
if !srcQualified && !destQualified { if !srcQualified {
return cli.moveSecretWithinVault(cmd, srcSecretName, destSecretName, force) vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
}
// Same vault? Use simple rename if possible (optimization)
if srcVaultName == destVaultName {
// Select the vault and do a simple move
err = vault.SelectVault(cli.fs, cli.stateDir, srcVaultName)
if err != nil { if err != nil {
return fmt.Errorf("failed to select vault '%s': %w", srcVaultName, err) return err
} }
return cli.moveSecretWithinVault(cmd, srcSecretName, destSecretName, force) return cli.moveSecretWithinVault(
cmd, vlt, srcSecretName, destSecretName, force)
} }
// Cross-vault move // Both vaults must be existing vaults by exact name, so that two
return cli.moveSecretCrossVault( // spellings of one vault, such as "work" and "work/", are never taken for
cmd, srcVaultName, srcSecretName, destVaultName, destSecretName, force) // two vaults. A named vault does not become the current vault.
} srcVault, err := cli.existingVault(srcVaultName)
// moveSecretWithinVault handles rename within the current vault. Its caller,
// MoveSecret, has already checked both secret names.
func (cli *Instance) moveSecretWithinVault(
cmd *cobra.Command, source, dest string, force bool,
) error {
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil { if err != nil {
return err return err
} }
vaultDir, err := currentVlt.GetDirectory() destVault, err := cli.existingVault(destVaultName)
if err != nil {
return err
}
if srcVaultName == destVaultName {
return cli.moveSecretWithinVault(
cmd, srcVault, srcSecretName, destSecretName, force)
}
return cli.moveSecretCrossVault(
cmd, srcVault, srcSecretName, destVault, destSecretName, force)
}
// existingVault returns the vault with the given name, or an error if there
// is none. Unlike vault.SelectVault, it leaves the current vault as it is.
func (cli *Instance) existingVault(name string) (*vault.Vault, error) {
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
if err != nil {
return nil, fmt.Errorf("failed to list vaults: %w", err)
}
if !slices.Contains(vaults, name) {
return nil, fmt.Errorf("vault '%s' %w", name, errVaultDoesNotExist)
}
return vault.NewVault(cli.fs, cli.stateDir, name), nil
}
// moveSecretWithinVault renames a secret within the vault vlt. Its caller,
// MoveSecret, has already checked both secret names.
func (cli *Instance) moveSecretWithinVault(
cmd *cobra.Command, vlt *vault.Vault, source, dest string, force bool,
) error {
// With --force the destination is removed before the source is renamed
// onto it, which would delete the secret.
if source == dest {
return fmt.Errorf("secret '%s' %w", source, errMoveOntoItself)
}
vaultDir, err := vlt.GetDirectory()
if err != nil { if err != nil {
return err return err
} }
@@ -829,57 +878,34 @@ func (cli *Instance) moveSecretWithinVault(
return nil return nil
} }
// moveSecretCrossVault handles moving between different vaults. Its caller, // moveSecretCrossVault handles moving between two different vaults. Its
// MoveSecret, has already checked both secret names. // caller, MoveSecret, has already checked both secret names and that both
// vaults exist.
func (cli *Instance) moveSecretCrossVault( func (cli *Instance) moveSecretCrossVault(
cmd *cobra.Command, cmd *cobra.Command,
srcVaultName, srcSecretName, srcVault *vault.Vault, srcSecretName string,
destVaultName, destSecretName string, destVault *vault.Vault, 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, srcVaultName) srcSecretName, errSecretNotFound, srcVault.Name)
}
// 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", destVaultName, err) return fmt.Errorf("failed to unlock destination vault '%s': %w", destVault.Name, err)
} }
// Count versions for user feedback // Count versions for user feedback
@@ -899,13 +925,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",
srcVaultName, srcSecretName, destVaultName, destSecretName, versionCount) srcVault.Name, srcSecretName, destVault.Name, 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",
srcVaultName, srcSecretName, destVaultName, destSecretName, versionCount) srcVault.Name, srcSecretName, destVault.Name, destSecretName, versionCount)
return nil return nil
} }
+4 -6
View File
@@ -265,9 +265,7 @@ 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
versionDir := filepath.Join(secretDir, "versions", version) exists, err := secret.VersionExists(cli.fs, secretDir, 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)
} }
@@ -323,9 +321,7 @@ func (cli *Instance) RemoveVersion(
} }
// Check if version exists // Check if version exists
versionDir := filepath.Join(secretDir, "versions", version) exists, err = secret.VersionExists(cli.fs, secretDir, 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)
} }
@@ -348,6 +344,8 @@ 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 current version (empty version string) // Test getting the current version
err = cli.GetSecretWithVersion(cmd, "test/secret", "") err = cli.GetSecret(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,6 +6,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"path/filepath" "path/filepath"
"slices"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -524,6 +525,18 @@ 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)
// Empty version should return current // An empty version is not one of the versions; GetSecret gets the
valueCurrent, err := vault.GetSecretVersion(secretName, "") // current one
require.NoError(t, err) _, err = vault.GetSecretVersion(secretName, "")
assert.Equal(t, []byte("version-3-data"), valueCurrent) require.ErrorIs(t, err, ErrVersionNotFound)
} }
func testPromoteOldVersion( func testPromoteOldVersion(
+30 -37
View File
@@ -259,18 +259,31 @@ func updateVersionMetadata(
return nil return nil
} }
// GetSecret retrieves a secret from this vault // GetSecret retrieves the current version of 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),
) )
return v.GetSecretVersion(name, "") // GetSecretObject validates the name and checks that the secret exists
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 (empty version // GetSecretVersion retrieves a specific version of a secret. The version
// means current) // must be one of the secret's versions; GetSecret gets the current one.
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),
@@ -278,8 +291,8 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
slog.String("version", version), slog.String("version", version),
) )
// Validate the name and resolve the version to fetch // Validate the name and check that the version exists
version, err := v.resolveSecretVersion(name, version) err := v.checkSecretVersion(name, version)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -640,15 +653,15 @@ func (v *Vault) updatePreviousVersion(
return nil return nil
} }
// resolveSecretVersion validates the secret name, verifies the secret and // checkSecretVersion validates the secret name and verifies that the secret
// version exist, and resolves an empty version to the current one. // exists and that version is one of its versions.
func (v *Vault) resolveSecretVersion(name, version string) (string, error) { func (v *Vault) checkSecretVersion(name, version 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
@@ -656,7 +669,7 @@ func (v *Vault) resolveSecretVersion(name, version string) (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
@@ -668,50 +681,30 @@ func (v *Vault) resolveSecretVersion(name, version string) (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
versionPath := filepath.Join(secretDir, "versions", version) exists, err = secret.VersionExists(v.fs, secretDir, 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( return fmt.Errorf("version '%s' %w '%s'", version, ErrVersionNotFound, name)
"version %s %w %s",
version, ErrVersionNotFound, name,
)
} }
return version, nil return 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)
// Get current (empty version) // An empty version is not one of the versions; GetSecret gets the
value, err = vault.GetSecretVersion(testSecretPath, "") // current one
require.NoError(t, err) _, err = vault.GetSecretVersion(testSecretPath, "")
assert.Equal(t, []byte("version-2"), value) require.ErrorIs(t, err, ErrVersionNotFound)
} }
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv //nolint:paralleltest // createTestVaultWithKey uses t.Setenv