All checks were successful
check / check (push) Successful in 2m0s
- Replace .golangci.yml with the canonical strict config (all linters enabled except the standard disable list; lll 88, funlen 80/50, cyclop 15, dupl 100; test files now linted) - Pin the Dockerfile lint stage to golangci/golangci-lint:v2.12.2 by tag and digest (Debian-based) - Fix all ~1550 findings surfaced by the new config: line wrapping, wsl_v5/nlreturn blank lines, noinlineerr splits, err113 sentinel errors, perfsprint/modernize rewrites, goconst constants, thelper, testifylint, noctx CommandContext, testpackage conversions, t.Parallel() where safe, and complexity/dupl helper extraction - Record the change and follow-up items in TODO.md User-visible strings -------------------- No user-visible string changes remain. Every error message this branch composes is byte-identical to the one main composes. The err113 sentinels are shaped so that fmt.Errorf reassembles the original text around them: a sentinel carries the fixed words of the message and the caller supplies the interpolated value in the position it has always occupied. Where the value sits in the middle of the sentence the sentinel therefore holds only a fragment (for example vault.ErrVaultNotFound is "does not exist", composed by its caller as "vault <name> does not exist"); each such sentinel documents the message it participates in. Verified mechanically rather than by inspection: every fmt.Errorf and errors.New call site in both trees was parsed, the Error() text of any sentinel passed to %w substituted in, and the resulting sets of composed message templates compared. All 350 templates main produces are still produced, character for character; the set of messages lost or altered is empty. unlocker list ------------- findUnlockerIDByMetadata now returns (string, error) instead of signalling failure with an empty ID. An unreadable unlockers.d is no longer indistinguishable from "no matching entry", so UnlockersList skips the entry with a warning naming the directory, as it did before the scan was extracted into a helper, rather than emitting a row under a synthesized fallback ID that no unlocker remove or unlocker select can match and that suppresses the current-unlocker marker. The duplicate-check and shell-completion callers skip on the same condition, matching their pre-extraction behavior. Covered by tests in internal/cli/unlockers_list_test.go.
890 lines
24 KiB
Go
890 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 {
|
|
// 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 {
|
|
// 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
|
|
}
|
|
|
|
// 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 {
|
|
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
|
|
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
|
|
}
|