check / check (push) Successful in 49s
`secret rm ..` resolved to the vault directory and deleted the whole vault; `secret rm .` and `secret rm ""` deleted every secret. rm, mv, the version commands, encrypt and decrypt built paths from the name without checking it; import checked it only after reading the source file. vault.ValidateSecretName wraps the existing name rule and its error states the rule. Each of those commands calls it on the name as given, before building any path; a move checks both names before switching the current vault. AddSecret, GetSecretVersion and GetSecretObject use it too. The regression test copies two in-memory vaults for each rejected command and requires the exact error and an unchanged state directory. Model: opus-5-5
923 lines
24 KiB
Go
923 lines
24 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"git.eeqj.de/sneak/secret/internal/vault"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
"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
|
|
|
|
// initialBufferSize is the starting size for secret read buffers (4KB)
|
|
initialBufferSize = 4 * 1024
|
|
// maxSecretSize is the maximum allowed size of a secret (100MB)
|
|
maxSecretSize = 100 * 1024 * 1024
|
|
)
|
|
|
|
// Sentinel errors for secret operations
|
|
var (
|
|
errSecretTooLarge = errors.New("secret too large: exceeds 100MB limit")
|
|
errSecretFileTooLarge = errors.New(
|
|
"secret file too large: exceeds 100MB limit")
|
|
errSecretNotFound = errors.New("not found")
|
|
errSecretExistsNoForce = errors.New(
|
|
"already exists (use --force to overwrite)")
|
|
errVaultDoesNotExist = errors.New("does not exist")
|
|
errCrossVaultSourceUnqualified = errors.New(
|
|
"source must specify vault (e.g., vault:secret) for cross-vault move")
|
|
)
|
|
|
|
// bufferInfo tracks a protected buffer and the number of bytes used in it
|
|
type bufferInfo struct {
|
|
buffer *memguard.LockedBuffer
|
|
used int
|
|
}
|
|
|
|
// 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 <secret-name>",
|
|
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
|
|
|
|
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 <secret-name>",
|
|
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)
|
|
}
|
|
|
|
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 <secret-name>",
|
|
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)
|
|
}
|
|
|
|
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 <secret-name>",
|
|
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.`,
|
|
Args: cobra.ExactArgs(1),
|
|
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
|
}
|
|
|
|
return cli.RemoveSecret(cmd, args[0], false)
|
|
},
|
|
}
|
|
|
|
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 <source> <destination>",
|
|
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)
|
|
}
|
|
|
|
return cli.MoveSecret(cmd, args[0], args[1], force)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolP("force", "f", false,
|
|
"Overwrite if destination secret already exists")
|
|
|
|
return cmd
|
|
}
|
|
|
|
// updateBufferSize updates the buffer size based on usage pattern
|
|
func updateBufferSize(currentSize int, sameSize *int) int {
|
|
const (
|
|
doubleAfterBuffers = 2
|
|
growthFactor = 2
|
|
)
|
|
|
|
*sameSize++
|
|
if *sameSize >= doubleAfterBuffers {
|
|
*sameSize = 0
|
|
|
|
return currentSize * growthFactor
|
|
}
|
|
|
|
return currentSize
|
|
}
|
|
|
|
// destroyBuffers destroys every buffer in the list
|
|
func destroyBuffers(buffers []bufferInfo) {
|
|
for _, b := range buffers {
|
|
b.buffer.Destroy()
|
|
}
|
|
}
|
|
|
|
// readSecretFromReader reads all data from reader into protected buffers,
|
|
// enforcing the maximum secret size. On failure the accumulated buffers
|
|
// are destroyed; on success the caller must destroy them.
|
|
func readSecretFromReader(reader io.Reader) ([]bufferInfo, int, error) {
|
|
var buffers []bufferInfo
|
|
|
|
totalSize := 0
|
|
currentBufferSize := initialBufferSize
|
|
sameSize := 0
|
|
|
|
for {
|
|
// Create a new buffer
|
|
buffer := memguard.NewBuffer(currentBufferSize)
|
|
n, err := io.ReadFull(reader, buffer.Bytes())
|
|
|
|
if n == 0 {
|
|
// No data read, destroy the unused buffer
|
|
buffer.Destroy()
|
|
} else {
|
|
buffers = append(buffers, bufferInfo{buffer: buffer, used: n})
|
|
totalSize += n
|
|
|
|
if totalSize > maxSecretSize {
|
|
destroyBuffers(buffers)
|
|
|
|
return nil, 0, errSecretTooLarge
|
|
}
|
|
|
|
// If we filled the buffer, consider growing for next iteration
|
|
if n == currentBufferSize {
|
|
currentBufferSize = updateBufferSize(currentBufferSize, &sameSize)
|
|
}
|
|
}
|
|
|
|
if err == io.EOF || errors.Is(err, io.ErrUnexpectedEOF) {
|
|
break
|
|
} else if err != nil {
|
|
destroyBuffers(buffers)
|
|
|
|
return nil, 0, err
|
|
}
|
|
}
|
|
|
|
return buffers, totalSize, nil
|
|
}
|
|
|
|
// combineBuffers copies the used portions of buffers into a single
|
|
// protected buffer of totalSize bytes
|
|
func combineBuffers(buffers []bufferInfo, totalSize int) *memguard.LockedBuffer {
|
|
valueBuffer := memguard.NewBuffer(totalSize)
|
|
|
|
offset := 0
|
|
for _, b := range buffers {
|
|
copy(valueBuffer.Bytes()[offset:], b.buffer.Bytes()[:b.used])
|
|
offset += b.used
|
|
}
|
|
|
|
return valueBuffer
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
secret.Debug("Got current vault", "vault_name", vlt.GetName())
|
|
|
|
// Read secret value directly into protected buffers
|
|
secret.Debug("Reading secret value from stdin into protected buffers")
|
|
|
|
buffers, totalSize, err := readSecretFromReader(cli.cmd.InOrStdin())
|
|
if err != nil {
|
|
if errors.Is(err, errSecretTooLarge) {
|
|
return err
|
|
}
|
|
|
|
return fmt.Errorf("failed to read secret value: %w", err)
|
|
}
|
|
defer destroyBuffers(buffers)
|
|
|
|
// Check for trailing newline in the last buffer
|
|
if len(buffers) > 0 && totalSize > 0 {
|
|
lastBuffer := &buffers[len(buffers)-1]
|
|
if lastBuffer.buffer.Bytes()[lastBuffer.used-1] == '\n' {
|
|
lastBuffer.used--
|
|
totalSize--
|
|
}
|
|
}
|
|
|
|
secret.Debug("Read secret value from stdin",
|
|
"value_length", totalSize, "buffers", len(buffers))
|
|
|
|
// Combine all buffers into a single protected buffer
|
|
valueBuffer := combineBuffers(buffers, totalSize)
|
|
defer valueBuffer.Destroy()
|
|
|
|
// Add the secret to the vault
|
|
secret.Debug("Calling vault.AddSecret", "secret_name", secretName,
|
|
"value_length", valueBuffer.Size(), "force", force)
|
|
|
|
err = vlt.AddSecret(secretName, valueBuffer, force)
|
|
if err != nil {
|
|
secret.Debug("vault.AddSecret failed", "error", err)
|
|
|
|
return err
|
|
}
|
|
|
|
secret.Debug("vault.AddSecret completed successfully")
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetSecret retrieves and prints a secret from the current vault
|
|
func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
|
|
return cli.GetSecretWithVersion(cmd, secretName, "")
|
|
}
|
|
|
|
// GetSecretWithVersion retrieves and prints a specific version of a secret
|
|
func (cli *Instance) GetSecretWithVersion(
|
|
cmd *cobra.Command, secretName string, version string,
|
|
) error {
|
|
secret.Debug("GetSecretWithVersion called",
|
|
"secretName", secretName, "version", version)
|
|
|
|
// Store the command for output
|
|
cli.cmd = cmd
|
|
|
|
// 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
|
|
}
|
|
|
|
// Get the secret value
|
|
var value []byte
|
|
if version == "" {
|
|
value, err = vlt.GetSecret(secretName)
|
|
} else {
|
|
value, err = vlt.GetSecretVersion(secretName, version)
|
|
}
|
|
|
|
if err != nil {
|
|
secret.Debug("Failed to get secret", "error", err)
|
|
|
|
return err
|
|
}
|
|
|
|
secret.Debug("Got secret value", "valueLength", len(value))
|
|
|
|
// Print the secret value to stdout
|
|
_, _ = cli.Print(string(value))
|
|
|
|
secret.Debug("Printed value to stdout")
|
|
|
|
// Debug: Log what we're actually printing
|
|
secret.Debug("Secret retrieval debug info",
|
|
"secretName", secretName,
|
|
"version", version,
|
|
"valueLength", len(value),
|
|
"valueAsString", string(value),
|
|
"isEmpty", len(value) == 0)
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
|
|
// Try to get metadata using GetSecretObject
|
|
secretObj, err := vlt.GetSecretObject(secretName)
|
|
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 <name>' 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 {
|
|
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
|
|
}
|
|
|
|
// Read secret value from the source file into protected buffers
|
|
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)
|
|
}
|
|
}()
|
|
|
|
buffers, totalSize, err := readSecretFromReader(file)
|
|
if err != nil {
|
|
if errors.Is(err, errSecretTooLarge) {
|
|
return errSecretFileTooLarge
|
|
}
|
|
|
|
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
|
|
}
|
|
defer destroyBuffers(buffers)
|
|
|
|
// Combine all buffers into a single protected buffer
|
|
valueBuffer := combineBuffers(buffers, totalSize)
|
|
defer valueBuffer.Destroy()
|
|
|
|
// Store the secret in the vault
|
|
err = vlt.AddSecret(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 from the vault
|
|
func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool) error {
|
|
err := vault.ValidateSecretName(secretName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get current vault
|
|
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Check if secret exists
|
|
vaultDir, err := currentVlt.GetDirectory()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
encodedName := strings.ReplaceAll(secretName, "/", "%")
|
|
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
|
|
|
|
exists, err := afero.DirExists(cli.fs, secretDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
|
}
|
|
|
|
if !exists {
|
|
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
|
|
}
|
|
|
|
// Count versions for information
|
|
versionsDir := filepath.Join(secretDir, "versions")
|
|
versionCount := 0
|
|
|
|
entries, err := afero.ReadDir(cli.fs, versionsDir)
|
|
if err == nil {
|
|
versionCount = len(entries)
|
|
}
|
|
|
|
// Remove the secret directory
|
|
err = cli.fs.RemoveAll(secretDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove secret: %w", err)
|
|
}
|
|
|
|
cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n",
|
|
secretName, versionCount)
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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 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 !destQualified {
|
|
// Check if dest is actually a vault name
|
|
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
|
if err == nil && slices.Contains(vaults, dest) {
|
|
// dest is a vault name, use source secret name
|
|
destVaultName = dest
|
|
destSecretName = srcSecretName
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Check both names before selecting a vault below, so that a rejected
|
|
// move leaves the current vault as it was.
|
|
err := vault.ValidateSecretName(srcSecretName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = vault.ValidateSecretName(destSecretName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 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 {
|
|
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 {
|
|
err := vault.ValidateSecretName(source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = vault.ValidateSecretName(dest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
vaultDir, err := currentVlt.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, errSecretNotFound)
|
|
}
|
|
|
|
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 {
|
|
if !force {
|
|
return fmt.Errorf("secret '%s' %w", dest, errSecretExistsNoForce)
|
|
}
|
|
|
|
err = cli.fs.RemoveAll(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
|
|
}
|
|
|
|
// moveSecretCrossVault handles moving between different vaults. Its caller,
|
|
// MoveSecret, has already checked both secret names.
|
|
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' %w", srcVaultName, errVaultDoesNotExist)
|
|
}
|
|
|
|
// 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, errSecretNotFound, srcVaultName)
|
|
}
|
|
|
|
// Get destination vault
|
|
destVault := vault.NewVault(cli.fs, cli.stateDir, destVaultName)
|
|
|
|
destVaultDir, err := destVault.GetDirectory()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
|
}
|
|
|
|
// Verify destination vault exists
|
|
exists, err = afero.DirExists(cli.fs, destVaultDir)
|
|
if err != nil || !exists {
|
|
return fmt.Errorf("destination vault '%s' %w",
|
|
destVaultName, errVaultDoesNotExist)
|
|
}
|
|
|
|
// Unlock destination vault (will fail if neither mnemonic nor unlocker available)
|
|
_, 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
|
|
err = destVault.CopySecretAllVersions(
|
|
srcVault, srcSecretName, destSecretName, force)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Delete source secret
|
|
err = cli.fs.RemoveAll(srcSecretDir)
|
|
if 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
|
|
}
|