From 128c53a11d054622173bb764b7e126e5b63ba60d Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 23 Dec 2025 15:24:13 +0700 Subject: [PATCH] Add cross-vault move command for secrets Implement syntax: secret move/mv : [:] - Copies all versions to destination vault with re-encryption - Deletes source after successful copy (true move) - Add --force flag to overwrite existing destination - Support both within-vault rename and cross-vault move - Add shell completion for vault:secret syntax - Include integration tests for cross-vault move --- CLAUDE.md | 83 +++++++++++-- internal/cli/completions.go | 55 ++++++++ internal/cli/integration_test.go | 89 +++++++++++++ internal/cli/secrets.go | 207 +++++++++++++++++++++++++++---- internal/vault/secrets.go | 155 +++++++++++++++++++++++ 5 files changed, 559 insertions(+), 30 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 595385d..cee9fc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,4 @@ -# Rules - -Read the rules in AGENTS.md and follow them. - -# Memory +# IMPORTANT RULES * Claude is an inanimate tool. The spam that Claude attempts to insert into commit messages (which it erroneously refers to as "attribution") is not @@ -16,10 +12,11 @@ Read the rules in AGENTS.md and follow them. * Code should always be formatted before committing. Do not commit unformatted code. -* Code should always be linted before committing. Do not commit - unlinted code. +* Code should always be linted and linter errors fixed before committing. + NEVER commit code that does not pass the linter. DO NOT modify the linter + config unless specifically instructed. -* The test suite is fast and local. When running tests, don't run +* The test suite is fast and local. When running tests, NEVER run individual parts of the test suite, always run the whole thing by running "make test". @@ -27,4 +24,72 @@ Read the rules in AGENTS.md and follow them. done provided to you in the initial instruction. Don't do part or most of the work, do all of the work until the criteria for done are met. -* When you complete each task, if the tests are passing and the code is formatted and there are no linter errors, always commit and push your work. Use a good commit message and don't mention any author or co-author attribution. \ No newline at end of file +* When you complete each task, if the tests are passing and the code is + formatted and there are no linter errors, always commit and push your + work. Use a good commit message and don't mention any author or co-author + attribution. + +* Do not create additional files in the root directory of the project + without asking permission first. Configuration files, documentation, and + build files are acceptable in the root, but source code and other files + should be organized in appropriate subdirectories. + +* Do not use bare strings or numbers in code, especially if they appear + anywhere more than once. Always define a constant (usually at the top of + the file) and give it a descriptive name, then use that constant in the + code instead of the bare string or number. + +* If you are fixing a bug, write a test first that reproduces the bug and + fails, and then fix the bug in the code, using the test to verify that the + fix worked. + +* When implementing new features, be aware of potential side-effects (such + as state files on disk, data in the database, etc.) and ensure that it is + possible to mock or stub these side-effects in tests when designing an + API. + +* When dealing with dates and times or timestamps, always use, display, and + store UTC. Set the local timezone to UTC on startup. If the user needs + to see the time in a different timezone, store the user's timezone in a + separate field and convert the UTC time to the user's timezone when + displaying it. For internal use and internal applications and + administrative purposes, always display UTC. + +* When implementing programs, put the main.go in + ./cmd//main.go and put the program's code in + ./internal//. This allows for multiple programs to be + implemented in the same repository without cluttering the root directory. + main.go should simply import and call .CLIEntry(). The + full implementation should be in ./internal//. + +* When you are instructed to make the tests pass, DO NOT delete tests, skip + tests, or change the tests specifically to make them pass (unless there + is a bug in the test). This is cheating, and it is bad. You should only + be modifying the test if it is incorrect or if the test is no longer + relevant. In almost all cases, you should be fixing the code that is + being tested, or updating the tests to match a refactored implementation. + +* Always write a `Makefile` with the default target being `test`, and with a + `fmt` target that formats the code. The `test` target should run all + tests in the project, and the `fmt` target should format the code. `test` + should also have a prerequisite target `lint` that should run any linters + that are configured for the project. + +* After each completed bugfix or feature, the code must be committed. Do + all of the pre-commit checks (test, lint, fmt) before committing, of + course. After each commit, push to the remote. + +* Always write tests, even if they are extremely simple and just check for + correct syntax (ability to compile/import). If you are writing a new + feature, write a test for it. You don't need to target complete coverage, + but you should at least test any new functionality you add. + +* Always use structured logging. Log any relevant state/context with the + messages (but do not log secrets). If stdout is not a terminal, output + the structured logs in jsonl format. Use go's log/slog. + +* You do not need to summarize your changes in the chat after making them. + Making the changes and committing them is sufficient. If anything out of + the ordinary happened, please explain it, but in the normal case where you + found and fixed the bug, or implemented the feature, there is no need for + the end-of-change summary. diff --git a/internal/cli/completions.go b/internal/cli/completions.go index a10bf3f..6b1602d 100644 --- a/internal/cli/completions.go +++ b/internal/cli/completions.go @@ -142,3 +142,58 @@ func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func( return completions, cobra.ShellCompDirectiveNoFileComp } } + +// getVaultSecretCompletionFunc returns a completion function for vault:secret format +// It completes vault names with ":" suffix, and after ":" it completes secrets from that vault +func getVaultSecretCompletionFunc(fs afero.Fs, stateDir string) func( + cmd *cobra.Command, args []string, toComplete string, +) ([]string, cobra.ShellCompDirective) { + return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + var completions []string + + // Check if we're completing after a vault: prefix + if strings.Contains(toComplete, ":") { + // Complete secret names for the specified vault + const vaultSecretParts = 2 + parts := strings.SplitN(toComplete, ":", vaultSecretParts) + vaultName := parts[0] + secretPrefix := parts[1] + + vlt := vault.NewVault(fs, stateDir, vaultName) + secrets, err := vlt.ListSecrets() + if err == nil { + for _, secretName := range secrets { + if strings.HasPrefix(secretName, secretPrefix) { + completions = append(completions, vaultName+":"+secretName) + } + } + } + + return completions, cobra.ShellCompDirectiveNoFileComp + } + + // Complete vault names with ":" suffix + vaults, err := vault.ListVaults(fs, stateDir) + if err == nil { + for _, v := range vaults { + if strings.HasPrefix(v, toComplete) { + completions = append(completions, v+":") + } + } + } + + // Also complete secrets from current vault (for within-vault moves) + if currentVlt, err := vault.GetCurrentVault(fs, stateDir); err == nil { + secrets, err := currentVlt.ListSecrets() + if err == nil { + for _, secretName := range secrets { + if strings.HasPrefix(secretName, toComplete) { + completions = append(completions, secretName) + } + } + } + } + + return completions, cobra.ShellCompDirectiveNoSpace + } +} diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index c0296f1..66aea95 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -200,6 +200,12 @@ func TestSecretManagerIntegration(t *testing.T) { // Expected: Secret moved to new location, old location removed test12bMoveSecret(t, testMnemonic, runSecret, runSecretWithStdin) + // Test 12c: Cross-vault move + // Commands: secret move work:secret default, secret move work:secret default:newname + // Purpose: Test moving secrets between vaults with re-encryption + // Expected: Secret copied to destination vault with all versions, source deleted + test12cCrossVaultMove(t, testMnemonic, runSecretWithEnv, runSecretWithStdin) + // Test 13: Unlocker management // Commands: secret unlocker list, secret unlocker add pgp // Purpose: Test multiple unlocker types @@ -1164,6 +1170,89 @@ func test12bMoveSecret(t *testing.T, testMnemonic string, runSecret func(...stri assert.Equal(t, "original-value", getOutput, "source should still have original value") } +func test12cCrossVaultMove(t *testing.T, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) { + env := map[string]string{ + "SB_SECRET_MNEMONIC": testMnemonic, + } + + // Create a test secret in the work vault + _, err := runSecretWithEnv(env, "vault", "select", "work") + require.NoError(t, err, "select work vault should succeed") + + // Add a secret with a version + _, err = runSecretWithStdin("cross-vault-value-v1", env, "add", "cross/move/test") + require.NoError(t, err, "add cross/move/test should succeed") + + // Add another version + _, err = runSecretWithStdin("cross-vault-value-v2", env, "add", "--force", "cross/move/test") + require.NoError(t, err, "add cross/move/test v2 should succeed") + + // Move to default vault using cross-vault syntax + output, err := runSecretWithEnv(env, "move", "work:cross/move/test", "default") + require.NoError(t, err, "cross-vault move should succeed") + assert.Contains(t, output, "Moved secret", "should show move confirmation") + assert.Contains(t, output, "2 version(s)", "should show version count") + + // Verify secret exists in default vault + _, err = runSecretWithEnv(env, "vault", "select", "default") + require.NoError(t, err, "select default vault should succeed") + + value, err := runSecretWithEnv(env, "get", "cross/move/test") + require.NoError(t, err, "get from default vault should succeed") + assert.Equal(t, "cross-vault-value-v2", value, "should have latest version value") + + // Verify secret no longer exists in work vault + _, err = runSecretWithEnv(env, "vault", "select", "work") + require.NoError(t, err, "select work vault should succeed") + + _, err = runSecretWithEnv(env, "get", "cross/move/test") + assert.Error(t, err, "get from work vault should fail after move") + + // Test cross-vault move with rename + _, err = runSecretWithStdin("rename-test", env, "add", "rename/source") + require.NoError(t, err, "add rename/source should succeed") + + output, err = runSecretWithEnv(env, "move", "work:rename/source", "default:renamed/dest") + require.NoError(t, err, "cross-vault move with rename should succeed") + assert.Contains(t, output, "Moved secret", "should show move confirmation") + + // Verify renamed secret exists in default vault + _, err = runSecretWithEnv(env, "vault", "select", "default") + require.NoError(t, err, "select default vault should succeed") + + value, err = runSecretWithEnv(env, "get", "renamed/dest") + require.NoError(t, err, "get renamed secret should succeed") + assert.Equal(t, "rename-test", value, "should have correct value") + + // Test --force flag for overwriting + _, err = runSecretWithStdin("existing-secret", env, "add", "force/test") + require.NoError(t, err, "add force/test in default should succeed") + + _, err = runSecretWithEnv(env, "vault", "select", "work") + require.NoError(t, err, "select work vault should succeed") + + _, err = runSecretWithStdin("new-value", env, "add", "force/test") + require.NoError(t, err, "add force/test in work should succeed") + + // Move without force should fail + output, err = runSecretWithEnv(env, "move", "work:force/test", "default") + assert.Error(t, err, "move without force should fail when dest exists") + assert.Contains(t, output, "already exists", "should indicate destination exists") + + // Move with force should succeed + output, err = runSecretWithEnv(env, "move", "--force", "work:force/test", "default") + require.NoError(t, err, "move with force should succeed") + assert.Contains(t, output, "Moved secret", "should show move confirmation") + + // Verify value was overwritten + _, err = runSecretWithEnv(env, "vault", "select", "default") + require.NoError(t, err, "select default vault should succeed") + + value, err = runSecretWithEnv(env, "get", "force/test") + require.NoError(t, err, "get overwritten secret should succeed") + assert.Equal(t, "new-value", value, "should have new value after force move") +} + func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) { // Make sure we're in default vault _, err := runSecret("vault", "select", "default") diff --git a/internal/cli/secrets.go b/internal/cli/secrets.go index 4a3996d..7962980 100644 --- a/internal/cli/secrets.go +++ b/internal/cli/secrets.go @@ -14,6 +14,25 @@ import ( "github.com/spf13/cobra" ) +const ( + // vaultSecretSeparator is the delimiter between vault name and secret name + vaultSecretSeparator = ":" + // vaultSecretParts is the number of parts when splitting vault:secret + vaultSecretParts = 2 +) + +// ParseVaultSecretRef parses a "vault:secret" or just "secret" reference +// Returns (vaultName, secretName, isQualified) +// If no vault is specified, returns empty vaultName and isQualified=false +func ParseVaultSecretRef(ref string) (vaultName, secretName string, isQualified bool) { + parts := strings.SplitN(ref, vaultSecretSeparator, vaultSecretParts) + if len(parts) == vaultSecretParts { + return parts[0], parts[1], true + } + + return "", ref, false +} + func newAddCmd() *cobra.Command { cmd := &cobra.Command{ Use: "add ", @@ -135,24 +154,32 @@ func newMoveCmd() *cobra.Command { Use: "move ", Aliases: []string{"mv", "rename"}, Short: "Move or rename a secret", - Long: `Move or rename a secret within the current vault. ` + - `If the destination already exists, the operation will fail.`, + Long: `Move a secret within a vault or between vaults. + +For within-vault moves (rename): + secret move old-name new-name + +For cross-vault moves: + secret move source-vault:secret-name dest-vault + secret move source-vault:secret-name dest-vault:new-name + +Cross-vault moves copy ALL versions of the secret, preserving history. +The source secret is deleted after successful copy.`, Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: source and destination ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - // Only complete the first argument (source) - if len(args) == 0 { - return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete) - } - - return nil, cobra.ShellCompDirectiveNoFileComp + // Complete vault:secret format + return getVaultSecretCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete) }, RunE: func(cmd *cobra.Command, args []string) error { + force, _ := cmd.Flags().GetBool("force") cli := NewCLIInstance() - return cli.MoveSecret(cmd, args[0], args[1]) + return cli.MoveSecret(cmd, args[0], args[1], force) }, } + cmd.Flags().BoolP("force", "f", false, "Overwrite if destination secret already exists") + return cmd } @@ -566,50 +593,188 @@ func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool) return nil } -// MoveSecret moves or renames a secret -func (cli *Instance) MoveSecret(cmd *cobra.Command, sourceName, destName string) error { - // Get current vault +// MoveSecret moves or renames a secret (within or across vaults) +func (cli *Instance) MoveSecret(cmd *cobra.Command, source, dest string, force bool) error { + // Parse source and destination + srcVaultName, srcSecretName, srcQualified := ParseVaultSecretRef(source) + destVaultName, destSecretName, destQualified := ParseVaultSecretRef(dest) + + // If neither is qualified, this is a simple within-vault rename + if !srcQualified && !destQualified { + return cli.moveSecretWithinVault(cmd, srcSecretName, destSecretName, force) + } + + // Cross-vault move requires source to be qualified + if !srcQualified { + return fmt.Errorf("source must specify vault (e.g., vault:secret) for cross-vault move") + } + + // If destination is not qualified (no colon), check if it's a vault name + // Format: "work:secret default" means move to vault "default" + // Format: "work:secret default:newname" means move to vault "default" with new name + if !destQualified { + // Check if dest is actually a vault name + vaults, err := vault.ListVaults(cli.fs, cli.stateDir) + if err == nil { + for _, v := range vaults { + if v == dest { + // dest is a vault name, use source secret name + destVaultName = dest + destSecretName = srcSecretName + + break + } + } + } + + // If destVaultName is still empty, dest is a secret name in source vault + if destVaultName == "" { + destVaultName = srcVaultName + destSecretName = dest + } + } + + // If destination secret name is empty, use source secret name + if destSecretName == "" { + destSecretName = srcSecretName + } + + // Same vault? Use simple rename if possible (optimization) + if srcVaultName == destVaultName { + // Select the vault and do a simple move + if err := vault.SelectVault(cli.fs, cli.stateDir, srcVaultName); err != nil { + return fmt.Errorf("failed to select vault '%s': %w", srcVaultName, err) + } + + return cli.moveSecretWithinVault(cmd, srcSecretName, destSecretName, force) + } + + // Cross-vault move + return cli.moveSecretCrossVault(cmd, srcVaultName, srcSecretName, destVaultName, destSecretName, force) +} + +// moveSecretWithinVault handles rename within the current vault +func (cli *Instance) moveSecretWithinVault(cmd *cobra.Command, source, dest string, force bool) error { currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) if err != nil { return err } - // Get vault directory vaultDir, err := currentVlt.GetDirectory() if err != nil { return err } - // Check if source exists - sourceEncoded := strings.ReplaceAll(sourceName, "/", "%") + sourceEncoded := strings.ReplaceAll(source, "/", "%") sourceDir := filepath.Join(vaultDir, "secrets.d", sourceEncoded) exists, err := afero.DirExists(cli.fs, sourceDir) if err != nil { return fmt.Errorf("failed to check if source secret exists: %w", err) } + if !exists { - return fmt.Errorf("secret '%s' not found", sourceName) + return fmt.Errorf("secret '%s' not found", source) } - // Check if destination already exists - destEncoded := strings.ReplaceAll(destName, "/", "%") + destEncoded := strings.ReplaceAll(dest, "/", "%") destDir := filepath.Join(vaultDir, "secrets.d", destEncoded) exists, err = afero.DirExists(cli.fs, destDir) if err != nil { return fmt.Errorf("failed to check if destination secret exists: %w", err) } + if exists { - return fmt.Errorf("secret '%s' already exists", destName) + if !force { + return fmt.Errorf("secret '%s' already exists (use --force to overwrite)", dest) + } + + if err := cli.fs.RemoveAll(destDir); err != nil { + return fmt.Errorf("failed to remove existing destination: %w", err) + } } - // Perform the move if err := cli.fs.Rename(sourceDir, destDir); err != nil { return fmt.Errorf("failed to move secret: %w", err) } - cmd.Printf("Moved secret '%s' to '%s'\n", sourceName, destName) + cmd.Printf("Moved secret '%s' to '%s'\n", source, dest) + + return nil +} + +// moveSecretCrossVault handles moving between different vaults +func (cli *Instance) moveSecretCrossVault( + cmd *cobra.Command, + srcVaultName, srcSecretName, + destVaultName, destSecretName string, + force bool, +) error { + // Get source vault + srcVault := vault.NewVault(cli.fs, cli.stateDir, srcVaultName) + srcVaultDir, err := srcVault.GetDirectory() + + if err != nil { + 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' does not exist", srcVaultName) + } + + // Verify source secret exists + srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%") + srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName) + + exists, err = afero.DirExists(cli.fs, srcSecretDir) + if err != nil || !exists { + return fmt.Errorf("secret '%s' not found in vault '%s'", srcSecretName, 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' does not exist", destVaultName) + } + + // Unlock destination vault (will fail if neither mnemonic nor unlocker available) + _, err = destVault.GetOrDeriveLongTermKey() + if err != nil { + return fmt.Errorf("failed to unlock destination vault '%s': %w", destVaultName, err) + } + + // Count versions for user feedback + versions, _ := secret.ListVersions(cli.fs, srcSecretDir) + versionCount := len(versions) + + // Copy all versions + if err := destVault.CopySecretAllVersions(srcVault, srcSecretName, destSecretName, force); err != nil { + return err + } + + // Delete source secret + if err := cli.fs.RemoveAll(srcSecretDir); err != nil { + // 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("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n", + srcVaultName, srcSecretName, destVaultName, destSecretName, versionCount) + + return nil + } + + cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n", + srcVaultName, srcSecretName, destVaultName, destSecretName, versionCount) return nil } diff --git a/internal/vault/secrets.go b/internal/vault/secrets.go index f836c9f..3452e0d 100644 --- a/internal/vault/secrets.go +++ b/internal/vault/secrets.go @@ -483,3 +483,158 @@ func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) { return secretObj, nil } + +// CopySecretVersion copies a single version from source to this vault +// It decrypts the value using srcIdentity and re-encrypts for this vault +func (v *Vault) CopySecretVersion( + srcVersion *secret.Version, + srcIdentity *age.X25519Identity, + destSecretName string, + destVersionName string, +) error { + secret.DebugWith("Copying secret version to vault", + slog.String("src_secret", srcVersion.SecretName), + slog.String("src_version", srcVersion.Version), + slog.String("dest_vault", v.Name), + slog.String("dest_secret", destSecretName), + slog.String("dest_version", destVersionName), + ) + + // Get the decrypted value from source + valueBuffer, err := srcVersion.GetValue(srcIdentity) + if err != nil { + return fmt.Errorf("failed to decrypt source version: %w", err) + } + defer valueBuffer.Destroy() + + // Load source metadata + if err := srcVersion.LoadMetadata(srcIdentity); err != nil { + return fmt.Errorf("failed to load source metadata: %w", err) + } + + // Create destination version with same name + destVersion := secret.NewVersion(v, destSecretName, destVersionName) + + // Copy metadata (preserve original timestamps) + destVersion.Metadata = srcVersion.Metadata + + // Save the version (encrypts to this vault's LT key) + if err := destVersion.Save(valueBuffer); err != nil { + return fmt.Errorf("failed to save destination version: %w", err) + } + + secret.Debug("Successfully copied secret version", + "src_version", srcVersion.Version, + "dest_version", destVersionName, + "dest_vault", v.Name) + + return nil +} + +// CopySecretAllVersions copies all versions of a secret from source vault to this vault +// It re-encrypts each version with this vault's long-term key +func (v *Vault) CopySecretAllVersions( + srcVault *Vault, + srcSecretName string, + destSecretName string, + force bool, +) error { + secret.DebugWith("Copying all secret versions between vaults", + slog.String("src_vault", srcVault.Name), + slog.String("src_secret", srcSecretName), + slog.String("dest_vault", v.Name), + slog.String("dest_secret", destSecretName), + slog.Bool("force", force), + ) + + // Get destination vault directory + destVaultDir, err := v.GetDirectory() + if err != nil { + return fmt.Errorf("failed to get destination vault directory: %w", err) + } + + // Check if destination secret already exists + destStorageName := strings.ReplaceAll(destSecretName, "/", "%") + destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName) + + exists, err := afero.DirExists(v.fs, destSecretDir) + if err != nil { + return fmt.Errorf("failed to check destination: %w", err) + } + + if exists && !force { + return fmt.Errorf("secret '%s' already exists in vault '%s' (use --force to overwrite)", + destSecretName, v.Name) + } + + if exists && force { + // Remove existing secret + secret.Debug("Removing existing destination secret", "path", destSecretDir) + if err := v.fs.RemoveAll(destSecretDir); err != nil { + return fmt.Errorf("failed to remove existing destination secret: %w", err) + } + } + + // Get source vault's long-term key + srcIdentity, err := srcVault.GetOrDeriveLongTermKey() + if err != nil { + return fmt.Errorf("failed to unlock source vault '%s': %w", srcVault.Name, err) + } + + // Get source secret directory + srcVaultDir, err := srcVault.GetDirectory() + if err != nil { + return fmt.Errorf("failed to get source vault directory: %w", err) + } + + srcStorageName := strings.ReplaceAll(srcSecretName, "/", "%") + srcSecretDir := filepath.Join(srcVaultDir, "secrets.d", srcStorageName) + + // List all versions + versions, err := secret.ListVersions(srcVault.fs, srcSecretDir) + if err != nil { + return fmt.Errorf("failed to list source versions: %w", err) + } + + if len(versions) == 0 { + return fmt.Errorf("source secret '%s' has no versions", srcSecretName) + } + + // Get current version name + currentVersion, err := secret.GetCurrentVersion(srcVault.fs, srcSecretDir) + if err != nil { + return fmt.Errorf("failed to get current version: %w", err) + } + + // Create destination secret directory + if err := v.fs.MkdirAll(destSecretDir, secret.DirPerms); err != nil { + return fmt.Errorf("failed to create destination secret directory: %w", err) + } + + // Copy each version + for _, versionName := range versions { + srcVersion := secret.NewVersion(srcVault, srcSecretName, versionName) + if err := v.CopySecretVersion(srcVersion, srcIdentity, destSecretName, versionName); err != nil { + // Rollback: remove partial copy + secret.Debug("Rolling back partial copy due to error", "error", err) + _ = v.fs.RemoveAll(destSecretDir) + + return fmt.Errorf("failed to copy version %s: %w", versionName, err) + } + } + + // Set current version + if err := secret.SetCurrentVersion(v.fs, destSecretDir, currentVersion); err != nil { + _ = v.fs.RemoveAll(destSecretDir) + + return fmt.Errorf("failed to set current version: %w", err) + } + + secret.DebugWith("Successfully copied all secret versions", + slog.String("src_vault", srcVault.Name), + slog.String("dest_vault", v.Name), + slog.Int("version_count", len(versions)), + ) + + return nil +}