package cli import ( "encoding/json" "errors" "fmt" "io" "log" "os" "path/filepath" "slices" "strings" "github.com/awnumar/memguard" "github.com/spf13/afero" "github.com/spf13/cobra" "golang.org/x/sys/unix" "sneak.berlin/go/secret/internal/secret" "sneak.berlin/go/secret/internal/vault" ) const ( // vaultSecretSeparator is the delimiter between vault name and secret name vaultSecretSeparator = ":" // vaultSecretParts is the number of parts when splitting vault:secret vaultSecretParts = 2 // maxSecretSize is the maximum allowed size of a secret (100MB) maxSecretSize = 100 * 1024 * 1024 // lockedMemoryReservePages is the locked memory, in pages, that `secret // add`, `secret import` and `secret generate secret` may need besides // the value they store, for keys and the read buffer: memguard locks // whole pages, at least one for each buffer. `secret add` was measured // to need 7 with both SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE set, // and 6 with one; the eighth is to spare. lockedMemoryReservePages = 8 // kibibyte is the unit of the locked-memory limit in `ulimit -l`. kibibyte = 1024 ) // Sentinel errors for secret operations var ( errSecretTooLarge = errors.New("secret too large: exceeds 100MB limit") errLockedMemoryLimit = errors.New( "secret too large for the locked-memory limit") errCrossVaultSourceUnqualified = errors.New( "source must specify vault (e.g., vault:secret) for cross-vault move") errMoveOntoItself = errors.New("cannot be moved onto itself") ) // 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) (string, string, 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 ", Short: "Add a secret to the vault", Long: `Add a secret to the current vault. The secret value is read from stdin.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { secret.Debug("Add command RunE starting", "secret_name", args[0]) force, _ := cmd.Flags().GetBool("force") secret.Debug("Got force flag", "force", force) cli, err := NewCLIInstance() if err != nil { return fmt.Errorf("failed to initialize CLI: %w", err) } cli.cmd = cmd // Set the command for stdin access // Before readSecretEnv, which makes the first locked allocation err = checkLockedMemoryLimit(0) if err != nil { return err } destroySecrets := cli.readSecretEnv() defer destroySecrets() secret.Debug("Created CLI instance, calling AddSecret") return cli.AddSecret(args[0], force) }, } cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret") return cmd } func newGetCmd() *cobra.Command { cli, err := NewCLIInstance() if err != nil { log.Fatalf("failed to initialize CLI: %v", err) } cmd := &cobra.Command{ Use: "get ", Short: "Retrieve a secret from the vault", Args: cobra.ExactArgs(1), ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir), RunE: func(cmd *cobra.Command, args []string) error { version, _ := cmd.Flags().GetString("version") cli, err := NewCLIInstance() if err != nil { return fmt.Errorf("failed to initialize CLI: %w", err) } destroySecrets := cli.readSecretEnv() defer destroySecrets() // 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) }, } cmd.Flags().StringP("version", "v", "", "Get a specific version (default: current)") return cmd } func newListCmd() *cobra.Command { cmd := &cobra.Command{ Use: "list [filter]", Aliases: []string{"ls"}, Short: "List all secrets in the current vault", Long: `List all secrets in the current vault. Optionally filter ` + `by substring match in secret name.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { jsonOutput, _ := cmd.Flags().GetBool("json") quietOutput, _ := cmd.Flags().GetBool("quiet") var filter string if len(args) > 0 { filter = args[0] } cli, err := NewCLIInstance() if err != nil { return fmt.Errorf("failed to initialize CLI: %w", err) } return cli.ListSecrets(cmd, jsonOutput, quietOutput, filter) }, } cmd.Flags().Bool("json", false, "Output in JSON format") cmd.Flags().BoolP("quiet", "q", false, "Output only secret names (for scripting)") return cmd } func newImportCmd() *cobra.Command { cmd := &cobra.Command{ Use: "import ", Short: "Import a secret from a file", Long: `Import a secret from a file and store it in the current ` + `vault under the given name.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { sourceFile, _ := cmd.Flags().GetString("source") force, _ := cmd.Flags().GetBool("force") cli, err := NewCLIInstance() if err != nil { return fmt.Errorf("failed to initialize CLI: %w", err) } // Before readSecretEnv, which makes the first locked allocation err = checkLockedMemoryLimit(0) if err != nil { return err } destroySecrets := cli.readSecretEnv() defer destroySecrets() return cli.ImportSecret(cmd, args[0], sourceFile, force) }, } cmd.Flags().StringP("source", "s", "", "Source file to import from (required)") cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret") _ = cmd.MarkFlagRequired("source") return cmd } func newRemoveCmd() *cobra.Command { cli, err := NewCLIInstance() if err != nil { log.Fatalf("failed to initialize CLI: %v", err) } cmd := &cobra.Command{ Use: "remove ", Aliases: []string{"rm"}, Short: "Remove a secret from the vault", Long: `Remove a secret and all its versions from the current ` + `vault. This action is permanent and cannot be undone. ` + `Asks for confirmation first; when stdin is not a terminal, ` + `fails unless --force is given.`, Args: cobra.ExactArgs(1), ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir), RunE: func(cmd *cobra.Command, args []string) error { force, _ := cmd.Flags().GetBool("force") cli, err := NewCLIInstance() if err != nil { return fmt.Errorf("failed to initialize CLI: %w", err) } return cli.RemoveSecret(cmd, args[0], force) }, } cmd.Flags().BoolP("force", "f", false, "Remove without asking for confirmation") return cmd } func newMoveCmd() *cobra.Command { cli, err := NewCLIInstance() if err != nil { log.Fatalf("failed to initialize CLI: %v", err) } cmd := &cobra.Command{ Use: "move ", Aliases: []string{"mv", "rename"}, Short: "Move or rename a secret", 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 // source and destination args ValidArgsFunction: func( cmd *cobra.Command, args []string, toComplete string, ) ([]string, cobra.ShellCompDirective) { // 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, err := NewCLIInstance() if err != nil { return fmt.Errorf("failed to initialize CLI: %w", err) } destroySecrets := cli.readSecretEnv() defer destroySecrets() return cli.MoveSecret(cmd, args[0], args[1], force) }, } cmd.Flags().BoolP("force", "f", false, "Overwrite if destination secret already exists") return cmd } // checkLockedMemoryLimit returns an error naming the locked-memory limit // (`ulimit -l`) when a value of size bytes does not fit in it with // lockedMemoryReservePages. memguard, which holds secret values in locked // memory, crashes or hangs the process when it cannot lock more, so a // command calls this with size 0 before its first locked allocation, and // again with the value's size before a buffer for the value is allocated. func checkLockedMemoryLimit(size int) error { var limit unix.Rlimit err := unix.Getrlimit(unix.RLIMIT_MEMLOCK, &limit) if err != nil { return fmt.Errorf("failed to read the locked-memory limit: %w", err) } // memguard locks whole pages, so the value is counted in whole pages. pageSize := os.Getpagesize() valuePages := (size + pageSize - 1) / pageSize //nolint:gosec // G115: a page count and a page size are never negative needed := uint64((valuePages + lockedMemoryReservePages) * pageSize) if needed > limit.Cur { return fmt.Errorf("%w of %d KiB (ulimit -l)", errLockedMemoryLimit, limit.Cur/kibibyte) } return nil } // readSecretFromReader reads all of reader into a new locked buffer, which // the caller must destroy, leaving out one trailing newline when trimNewline // is set. It reads a page at a time into a memguard.Stream, which keeps what // it is given encrypted, so that the value is in locked memory only once, in // the buffer returned, and both size checks come before that buffer is // allocated. It takes the value back out with Stream.Next, not Stream.Read: // Read puts back the part of a piece that does not fit, and memguard hangs // when it runs out of locked memory doing that. func readSecretFromReader( reader io.Reader, trimNewline bool, ) (*memguard.LockedBuffer, error) { stream := memguard.NewStream() chunk := memguard.NewBuffer(os.Getpagesize()) defer chunk.Destroy() size := 0 endsWithNewline := false for { n, err := io.ReadFull(reader, chunk.Bytes()) if n > 0 { size += n if size > maxSecretSize { return nil, errSecretTooLarge } endsWithNewline = chunk.Bytes()[n-1] == '\n' // Write encrypts the bytes and wipes them from chunk. _, _ = stream.Write(chunk.Bytes()[:n]) } if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { break } if err != nil { return nil, err } } // Freed before the value's buffer is allocated, so that the two are // never locked at once. chunk.Destroy() if trimNewline && endsWithNewline { size-- } err := checkLockedMemoryLimit(size) if err != nil { return nil, err } value := memguard.NewBuffer(size) // copy stops at the value's size, so a trimmed newline is not copied. for offset := 0; offset < size; { decrypted, err := stream.Next() if err != nil { value.Destroy() return nil, fmt.Errorf("failed to decrypt the value read: %w", err) } offset += copy(value.Bytes()[offset:], decrypted.Bytes()) decrypted.Destroy() } return value, nil } // AddSecret adds a secret to the current vault func (cli *Instance) AddSecret(secretName string, force bool) error { secret.Debug("CLI AddSecret starting", "secret_name", secretName, "force", force) // Get current vault secret.Debug("Getting current vault") vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) if err != nil { return err } vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase secret.Debug("Got current vault", "vault_name", vlt.GetName()) // Read the secret value, less a trailing newline, into locked memory secret.Debug("Reading secret value from stdin into a protected buffer") valueBuffer, err := readSecretFromReader(cli.cmd.InOrStdin(), true) if err != nil { if errors.Is(err, errSecretTooLarge) || errors.Is(err, errLockedMemoryLimit) { return err } return fmt.Errorf("failed to read secret value: %w", err) } defer valueBuffer.Destroy() secret.Debug("Read secret value from stdin", "value_length", valueBuffer.Size()) // Locked only now that stdin has been read: in `secret encrypt key | // secret add name`, holding the lock while reading would leave each // command waiting for the other. return cli.storeSecret(vlt, secretName, valueBuffer, force) } // storeSecret adds the secret name with value to vlt, as vault.AddSecret // does, holding the state directory lock while it does. With force, adding // an existing secret replaces its current version, which needs the vault's // long-term key. Getting the key can ask for the passphrase, so storeSecret // gets it before it takes the lock, and no other command waits while the // user answers. If another command adds the secret while this one waits for // the lock, storeSecret lets go of the lock, gets the key, and takes the // lock again. func (cli *Instance) storeSecret( vlt *vault.Vault, name string, value *memguard.LockedBuffer, force bool, ) error { err := vault.ValidateSecretName(name) if err != nil { return err } replacing, err := replacesVersion(vlt, name, force) if err != nil { return err } if replacing { _, err = vlt.GetOrDeriveLongTermKey() if err != nil { return fmt.Errorf("failed to get long-term key: %w", err) } } needsKey, err := cli.addWithoutAsking(vlt, name, value, force) if !needsKey { return err } _, err = vlt.GetOrDeriveLongTermKey() if err != nil { return fmt.Errorf("failed to get long-term key: %w", err) } // With the key in memory, this adds the secret _, err = cli.addWithoutAsking(vlt, name, value, force) return err } // addWithoutAsking takes the state directory lock and adds the secret name // with value to vlt, as vault.AddSecret does. If that would replace the // secret's current version while the vault's long-term key is not in // memory, getting the key could ask for the passphrase with the lock held: // addWithoutAsking then adds nothing and returns true. func (cli *Instance) addWithoutAsking( vlt *vault.Vault, name string, value *memguard.LockedBuffer, force bool, ) (bool, error) { release, err := vault.LockStateDir(cli.fs, cli.stateDir) if err != nil { return false, err } defer release() replacing, err := replacesVersion(vlt, name, force) if err != nil { return false, err } if replacing && vlt.Locked() { return true, nil } return false, vlt.AddSecret(name, value, force) } // replacesVersion reports whether adding the secret name to vlt replaces // the secret's current version: whether force is set and the secret has one. func replacesVersion(vlt *vault.Vault, name string, force bool) (bool, error) { if !force { return false, nil } exists, err := secret.NewSecret(vlt, name).Exists() if err != nil { return false, fmt.Errorf("failed to check if secret exists: %w", err) } return exists, nil } // GetSecret retrieves and prints the current version of a secret func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error { secret.Debug("GetSecret called", "secretName", secretName) // Get current vault vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) if err != nil { return err } vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase value, err := vlt.GetSecret(secretName) if err != nil { return err } defer value.Destroy() // Write the value straight from locked memory, with no trailing newline _, err = cmd.OutOrStdout().Write(value.Bytes()) if err != nil { return fmt.Errorf("failed to write secret value: %w", err) } return nil } // GetSecretWithVersion retrieves and prints a specific version of a secret. // The version must be one of the secret's versions. func (cli *Instance) GetSecretWithVersion( cmd *cobra.Command, secretName string, version string, ) error { secret.Debug("GetSecretWithVersion called", "secretName", secretName, "version", version) // Get current vault vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) if err != nil { secret.Debug("Failed to get current vault", "error", err) return err } vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase // Get the secret value value, err := vlt.GetSecretVersion(secretName, version) if err != nil { secret.Debug("Failed to get secret", "error", err) return err } defer value.Destroy() secret.Debug("Got secret value", "valueLength", value.Size()) // Write the value straight from locked memory, with no trailing newline _, err = cmd.OutOrStdout().Write(value.Bytes()) if err != nil { return fmt.Errorf("failed to write secret value: %w", err) } secret.Debug("Printed value to stdout") return nil } // ListSecrets lists all secrets in the current vault func (cli *Instance) ListSecrets( cmd *cobra.Command, jsonOutput bool, quietOutput bool, filter string, ) error { // Get current vault vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) if err != nil { return err } // Get list of secrets secrets, err := vlt.ListSecrets() if err != nil { return fmt.Errorf("failed to list secrets: %w", err) } // Filter secrets if filter is provided var filteredSecrets []string if filter != "" { for _, secretName := range secrets { if strings.Contains(secretName, filter) { filteredSecrets = append(filteredSecrets, secretName) } } } else { filteredSecrets = secrets } switch { case jsonOutput: return printSecretsJSON(cmd, vlt, filteredSecrets, filter) case quietOutput: // Quiet output - just secret names for _, secretName := range filteredSecrets { _, _ = fmt.Fprintln(cmd.OutOrStdout(), secretName) } return nil default: return printSecretsTable(cmd, vlt, filteredSecrets, filter, len(secrets)) } } // printSecretsJSON prints the filtered secrets with metadata as JSON func printSecretsJSON( cmd *cobra.Command, vlt *vault.Vault, filteredSecrets []string, filter string, ) error { // For JSON output, get metadata for each secret secretsWithMetadata := make([]map[string]any, 0, len(filteredSecrets)) for _, secretName := range filteredSecrets { secretInfo := map[string]any{ "name": secretName, } // A secret whose times cannot be read is listed without them secretObj, err := vlt.GetSecretObject(secretName) if err == nil { err = secretObj.LoadMetadata() } if err == nil { metadata := secretObj.GetMetadata() secretInfo["created_at"] = metadata.CreatedAt secretInfo["updated_at"] = metadata.UpdatedAt } secretsWithMetadata = append(secretsWithMetadata, secretInfo) } output := map[string]any{ "secrets": secretsWithMetadata, } if filter != "" { output["filter"] = filter } jsonBytes, err := json.MarshalIndent(output, "", " ") if err != nil { return fmt.Errorf("failed to marshal JSON: %w", err) } _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(jsonBytes)) return nil } // printSecretsTable prints the filtered secrets as a formatted table func printSecretsTable( cmd *cobra.Command, vlt *vault.Vault, filteredSecrets []string, filter string, totalCount int, ) error { // Pretty table output out := cmd.OutOrStdout() if len(filteredSecrets) == 0 { if filter != "" { _, _ = fmt.Fprintf(out, "No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter) } else { _, _ = fmt.Fprintln(out, "No secrets found in current vault.") _, _ = fmt.Fprintln(out, "Run 'secret add ' to create one.") } return nil } // Get current vault name for display if filter != "" { _, _ = fmt.Fprintf(out, "Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter) } else { _, _ = fmt.Fprintf(out, "Secrets in vault '%s':\n\n", vlt.GetName()) } // Calculate the maximum name length for proper column alignment maxNameLen := len("NAME") // Start with header length for _, secretName := range filteredSecrets { if len(secretName) > maxNameLen { maxNameLen = len(secretName) } } // Add some padding maxNameLen += 2 // Print headers with dynamic width nameFormat := fmt.Sprintf("%%-%ds", maxNameLen) _, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", "NAME", "LAST UPDATED") _, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", strings.Repeat("-", len("NAME")), "------------") for _, secretName := range filteredSecrets { lastUpdated := "unknown" secretObj, err := vlt.GetSecretObject(secretName) if err == nil { err = secretObj.LoadMetadata() } if err == nil { metadata := secretObj.GetMetadata() lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04") } _, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", secretName, lastUpdated) } _, _ = fmt.Fprintf(out, "\nTotal: %d secret(s)", len(filteredSecrets)) if filter != "" { _, _ = fmt.Fprintf(out, " (filtered from %d)", totalCount) } _, _ = fmt.Fprintln(out) return nil } // ImportSecret imports a secret from a file func (cli *Instance) ImportSecret( cmd *cobra.Command, secretName, sourceFile string, force bool, ) error { err := vault.ValidateSecretName(secretName) if err != nil { return err } // Get current vault vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) if err != nil { return err } vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase // Read secret value from the source file into a protected buffer file, err := cli.fs.Open(sourceFile) if err != nil { return fmt.Errorf("failed to open file %s: %w", sourceFile, err) } defer func() { closeErr := file.Close() if closeErr != nil { secret.Warn("Failed to close file", "error", closeErr) } }() valueBuffer, err := readSecretFromReader(file, false) if err != nil { return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err) } defer valueBuffer.Destroy() // Locked only now that the file has been read, as in AddSecret: the // file may be a pipe written by another secret command. err = cli.storeSecret(vlt, secretName, valueBuffer, force) if err != nil { return err } cmd.Printf("Successfully imported secret '%s' from file '%s'\n", secretName, sourceFile) return nil } // RemoveSecret removes a secret and all its versions from the current // vault, after asking the user to confirm unless force is set. func (cli *Instance) RemoveSecret( cmd *cobra.Command, secretName string, force bool, ) error { err := vault.ValidateSecretName(secretName) if err != nil { return err } var found secretToRemove release, err := cli.askThenLock(cmd, force, func() (string, error) { var err error found, err = cli.findSecretToRemove(secretName) return found.question, err }) if err != nil { return err } defer release() err = secret.RemoveDirAtomic(cli.fs, found.dir) if err != nil { return fmt.Errorf("failed to remove secret: %w", err) } cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n", secretName, found.versions) return nil } // secretToRemove is what removing a secret removes, as findSecretToRemove // found it. type secretToRemove struct { // dir is the secret's directory, which holds all its versions. dir string versions int // question names what is removed, for the user to confirm. question string } // findSecretToRemove checks that the secret exists in the current vault // and counts its versions. func (cli *Instance) findSecretToRemove( secretName string, ) (secretToRemove, error) { currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) if err != nil { return secretToRemove{}, err } vaultDir, err := currentVlt.GetDirectory() if err != nil { return secretToRemove{}, err } encodedName := strings.ReplaceAll(secretName, "/", "%") secretDir := filepath.Join(vaultDir, "secrets.d", encodedName) exists, err := afero.DirExists(cli.fs, secretDir) if err != nil { return secretToRemove{}, fmt.Errorf("failed to check if secret exists: %w", err) } if !exists { return secretToRemove{}, fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound) } // A secret without a versions directory has no versions, and can // still be removed. versions, err := afero.ReadDir(cli.fs, filepath.Join(secretDir, "versions")) if err != nil && !errors.Is(err, os.ErrNotExist) { return secretToRemove{}, fmt.Errorf( "failed to count the versions of secret '%s': %w", secretName, err) } return secretToRemove{ dir: secretDir, versions: len(versions), question: fmt.Sprintf("Permanently remove secret '%s' and its %d "+ "version(s) from vault '%s'?", secretName, len(versions), currentVlt.GetName()), }, nil } // MoveSecret moves or renames a secret (within or across vaults), holding // the state directory lock. func (cli *Instance) MoveSecret( cmd *cobra.Command, source, dest string, force bool, ) error { release, err := vault.LockStateDir(cli.fs, cli.stateDir) if err != nil { return err } defer release() return cli.moveSecret(cmd, source, dest, force) } // moveSecret does the work of MoveSecret. The caller holds the state // directory lock. 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) // Cross-vault move requires source to be qualified if !srcQualified && destQualified { return errCrossVaultSourceUnqualified } // 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 a new name if srcQualified && !destQualified { // Check if dest is actually a vault name _, err := cli.existingVault(dest) if err == nil { // dest is a vault name, use source secret name destVaultName = dest destSecretName = srcSecretName } else { // dest is a secret name in source vault destVaultName = srcVaultName } } // If destination secret name is empty, use source secret name. A plain // rename keeps it empty, so that the check below rejects it. if srcQualified && destSecretName == "" { destSecretName = srcSecretName } // Check both names, for every form of the move, before building any path // from them. err := vault.ValidateSecretName(srcSecretName) if err != nil { return err } err = vault.ValidateSecretName(destSecretName) if err != nil { return err } // Neither name is qualified: a rename within the current vault. if !srcQualified { vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) if err != nil { return err } return cli.moveSecretWithinVault( cmd, vlt, srcSecretName, destSecretName, force) } // Both vault names must be valid and name existing vaults exactly, so // that two 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( cmd, srcVault, srcSecretName, destVault, destSecretName, force) } // existingVault returns the vault with the given name, or an error if the // name is not a valid vault name or there is no such vault. Unlike // vault.SelectVault, it leaves the current vault as it is. func (cli *Instance) existingVault(name string) (*vault.Vault, error) { err := vault.ValidateVaultName(name) if err != nil { return nil, err } 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, vault.ErrVaultNotFound) } 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 { return err } 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' %w", source, vault.ErrSecretNotFound) } destEncoded := strings.ReplaceAll(dest, "/", "%") destDir := filepath.Join(vaultDir, "secrets.d", destEncoded) // Removing a destination that is the source under another name, such as // "foo" for "Foo" on a case-insensitive filesystem, would delete it too. same, err := cli.sameDirectory(sourceDir, destDir) if err != nil { return err } if same { return fmt.Errorf("secret '%s' %w: '%s' is the same secret on "+ "this filesystem", source, errMoveOntoItself, dest) } exists, err = afero.DirExists(cli.fs, destDir) if err != nil { return fmt.Errorf("failed to check if destination secret exists: %w", err) } if exists { if !force { return fmt.Errorf("secret '%s' %w (use --force to overwrite)", dest, vault.ErrSecretExists) } err = secret.RemoveDirAtomic(cli.fs, destDir) if err != nil { return fmt.Errorf("failed to remove existing destination: %w", err) } } err = cli.fs.Rename(sourceDir, destDir) if err != nil { return fmt.Errorf("failed to move secret: %w", err) } cmd.Printf("Moved secret '%s' to '%s'\n", source, dest) return nil } // sameDirectory reports whether the existing directory dir and the path // other are one directory under two names, as secrets.d/Foo and // secrets.d/foo are on a case-insensitive filesystem, or a directory and a // symbolic link to it. Removing other to make room for dir would then delete // dir. It is false if other does not exist, and always false on the // in-memory filesystem, which has no such aliasing and whose files // os.SameFile does not compare. func (cli *Instance) sameDirectory(dir, other string) (bool, error) { dirInfo, err := cli.fs.Stat(dir) if err != nil { return false, fmt.Errorf("failed to check %s: %w", dir, err) } otherInfo, err := cli.fs.Stat(other) if errors.Is(err, os.ErrNotExist) { return false, nil } if err != nil { return false, fmt.Errorf("failed to check %s: %w", other, err) } return os.SameFile(dirInfo, otherInfo), nil } // moveSecretCrossVault handles moving between two different vaults. Its // caller, MoveSecret, has already checked both secret names and that both // vaults exist. func (cli *Instance) moveSecretCrossVault( cmd *cobra.Command, srcVault *vault.Vault, srcSecretName string, destVault *vault.Vault, destSecretName string, force bool, ) error { srcVaultDir, err := srcVault.GetDirectory() if err != nil { return fmt.Errorf("failed to get source vault directory: %w", err) } // 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' %w in vault '%s'", srcSecretName, vault.ErrSecretNotFound, srcVault.Name) } // The source is removed after the copy, so a destination that is the // source under another name would be lost with it. destVaultDir, err := destVault.GetDirectory() if err != nil { return fmt.Errorf("failed to get destination vault directory: %w", err) } destStorageName := strings.ReplaceAll(destSecretName, "/", "%") destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName) same, err := cli.sameDirectory(srcSecretDir, destSecretDir) if err != nil { return err } if same { return fmt.Errorf("secret '%s:%s' %w: '%s:%s' is the same secret on "+ "this filesystem", srcVault.Name, srcSecretName, errMoveOntoItself, destVault.Name, destSecretName) } // Copying needs the long-term keys of both vaults srcVault.Mnemonic, srcVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase destVault.Mnemonic, destVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase // 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", destVault.Name, err) } // Count versions for user feedback versions, _ := secret.ListVersions(cli.fs, srcSecretDir) versionCount := len(versions) // Copy all versions err = destVault.CopySecretAllVersions( srcVault, srcSecretName, destSecretName, force) if err != nil { return err } // Delete source secret err = secret.RemoveDirAtomic(cli.fs, srcSecretDir) if err != nil { return cli.sourceRemovalError(err, srcSecretDir, srcVault.Name+":"+srcSecretName, destVault.Name+":"+destSecretName, versionCount) } cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n", srcVault.Name, srcSecretName, destVault.Name, destSecretName, versionCount) return nil } // sourceRemovalError returns the error of a move between vaults that // copied all versionCount versions of the secret srcName to destName, and // then failed with removeErr to remove srcSecretDir, the directory of // srcName. The removal can fail after the source has left its vault, when // only the temporary directory it was moved into cannot be deleted, so the // error says the source remains only if srcSecretDir is still there. func (cli *Instance) sourceRemovalError( removeErr error, srcSecretDir, srcName, destName string, versionCount int, ) error { remains, err := afero.DirExists(cli.fs, srcSecretDir) switch { case err != nil: return fmt.Errorf("secret '%s' is copied with all %d version(s) "+ "to '%s', but removing the source failed and whether it "+ "remains is unknown: %w", srcName, versionCount, destName, errors.Join(removeErr, err)) case remains: return fmt.Errorf("secret '%s' is copied with all %d version(s) "+ "to '%s', but the source remains: %w", srcName, versionCount, destName, removeErr) default: return fmt.Errorf("secret '%s' is moved with all %d version(s) "+ "to '%s', but its old files could not all be deleted: %w", srcName, versionCount, destName, removeErr) } }