Update golangci-lint to v2.12.2 with canonical config (closes #30)
All checks were successful
check / check (push) Successful in 2m0s
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.
This commit is contained in:
@@ -2,10 +2,12 @@ package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
@@ -20,12 +22,36 @@ const (
|
||||
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) (vaultName, secretName string, isQualified bool) {
|
||||
func ParseVaultSecretRef(ref string) (string, string, bool) {
|
||||
parts := strings.SplitN(ref, vaultSecretSeparator, vaultSecretParts)
|
||||
if len(parts) == vaultSecretParts {
|
||||
return parts[0], parts[1], true
|
||||
@@ -42,6 +68,7 @@ func newAddCmd() *cobra.Command {
|
||||
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)
|
||||
|
||||
@@ -49,7 +76,9 @@ func newAddCmd() *cobra.Command {
|
||||
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)
|
||||
@@ -66,6 +95,7 @@ func newGetCmd() *cobra.Command {
|
||||
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",
|
||||
@@ -73,6 +103,7 @@ func newGetCmd() *cobra.Command {
|
||||
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)
|
||||
@@ -92,8 +123,9 @@ func newListCmd() *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),
|
||||
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")
|
||||
@@ -122,8 +154,9 @@ 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),
|
||||
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")
|
||||
@@ -149,12 +182,13 @@ func newRemoveCmd() *cobra.Command {
|
||||
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.`,
|
||||
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 {
|
||||
@@ -175,6 +209,7 @@ func newMoveCmd() *cobra.Command {
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize CLI: %v", err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "move <source> <destination>",
|
||||
Aliases: []string{"mv", "rename"},
|
||||
@@ -190,13 +225,16 @@ For cross-vault moves:
|
||||
|
||||
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) {
|
||||
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)
|
||||
@@ -206,16 +244,20 @@ The source secret is deleted after successful copy.`,
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolP("force", "f", false, "Overwrite if destination secret already exists")
|
||||
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++
|
||||
const doubleAfterBuffers = 2
|
||||
const growthFactor = 2
|
||||
if *sameSize >= doubleAfterBuffers {
|
||||
*sameSize = 0
|
||||
|
||||
@@ -225,40 +267,21 @@ func updateBufferSize(currentSize int, sameSize *int) int {
|
||||
return currentSize
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
const initialSize = 4 * 1024 // 4KB initial buffer
|
||||
const maxSize = 100 * 1024 * 1024 // 100MB max
|
||||
|
||||
type bufferInfo struct {
|
||||
buffer *memguard.LockedBuffer
|
||||
used int
|
||||
// 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
|
||||
defer func() {
|
||||
for _, b := range buffers {
|
||||
b.buffer.Destroy()
|
||||
}
|
||||
}()
|
||||
|
||||
reader := cli.cmd.InOrStdin()
|
||||
totalSize := 0
|
||||
currentBufferSize := initialSize
|
||||
currentBufferSize := initialBufferSize
|
||||
sameSize := 0
|
||||
|
||||
for {
|
||||
@@ -273,8 +296,10 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
|
||||
buffers = append(buffers, bufferInfo{buffer: buffer, used: n})
|
||||
totalSize += n
|
||||
|
||||
if totalSize > maxSize {
|
||||
return fmt.Errorf("secret too large: exceeds 100MB limit")
|
||||
if totalSize > maxSecretSize {
|
||||
destroyBuffers(buffers)
|
||||
|
||||
return nil, 0, errSecretTooLarge
|
||||
}
|
||||
|
||||
// If we filled the buffer, consider growing for next iteration
|
||||
@@ -283,13 +308,59 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
if err == io.EOF || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
break
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to read secret value: %w", err)
|
||||
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]
|
||||
@@ -299,21 +370,19 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
secret.Debug("Read secret value from stdin", "value_length", totalSize, "buffers", len(buffers))
|
||||
secret.Debug("Read secret value from stdin",
|
||||
"value_length", totalSize, "buffers", len(buffers))
|
||||
|
||||
// Combine all buffers into a single protected buffer
|
||||
valueBuffer := memguard.NewBuffer(totalSize)
|
||||
valueBuffer := combineBuffers(buffers, totalSize)
|
||||
defer valueBuffer.Destroy()
|
||||
|
||||
offset := 0
|
||||
for _, b := range buffers {
|
||||
copy(valueBuffer.Bytes()[offset:], b.buffer.Bytes()[:b.used])
|
||||
offset += b.used
|
||||
}
|
||||
|
||||
// Add the secret to the vault
|
||||
secret.Debug("Calling vault.AddSecret", "secret_name", secretName, "value_length", valueBuffer.Size(), "force", force)
|
||||
if err := vlt.AddSecret(secretName, valueBuffer, force); err != nil {
|
||||
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
|
||||
@@ -330,8 +399,11 @@ func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
|
||||
}
|
||||
|
||||
// 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)
|
||||
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
|
||||
@@ -351,6 +423,7 @@ func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string,
|
||||
} else {
|
||||
value, err = vlt.GetSecretVersion(secretName, version)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get secret", "error", err)
|
||||
|
||||
@@ -361,6 +434,7 @@ func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string,
|
||||
|
||||
// Print the secret value to stdout
|
||||
_, _ = cli.Print(string(value))
|
||||
|
||||
secret.Debug("Printed value to stdout")
|
||||
|
||||
// Debug: Log what we're actually printing
|
||||
@@ -375,7 +449,9 @@ func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string,
|
||||
}
|
||||
|
||||
// ListSecrets lists all secrets in the current vault
|
||||
func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutput bool, filter string) error {
|
||||
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 {
|
||||
@@ -390,6 +466,7 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
|
||||
|
||||
// Filter secrets if filter is provided
|
||||
var filteredSecrets []string
|
||||
|
||||
if filter != "" {
|
||||
for _, secretName := range secrets {
|
||||
if strings.Contains(secretName, filter) {
|
||||
@@ -400,100 +477,132 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
|
||||
filteredSecrets = secrets
|
||||
}
|
||||
|
||||
if jsonOutput { //nolint:nestif // Separate JSON and table output formatting logic
|
||||
// For JSON output, get metadata for each secret
|
||||
secretsWithMetadata := make([]map[string]interface{}, 0, len(filteredSecrets))
|
||||
|
||||
for _, secretName := range filteredSecrets {
|
||||
secretInfo := map[string]interface{}{
|
||||
"name": secretName,
|
||||
}
|
||||
|
||||
// Try to get metadata using GetSecretObject
|
||||
if secretObj, err := vlt.GetSecretObject(secretName); err == nil {
|
||||
metadata := secretObj.GetMetadata()
|
||||
secretInfo["created_at"] = metadata.CreatedAt
|
||||
secretInfo["updated_at"] = metadata.UpdatedAt
|
||||
}
|
||||
|
||||
secretsWithMetadata = append(secretsWithMetadata, secretInfo)
|
||||
}
|
||||
|
||||
output := map[string]interface{}{
|
||||
"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))
|
||||
} else if quietOutput {
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
// 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"
|
||||
if secretObj, err := vlt.GetSecretObject(secretName); 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)", len(secrets))
|
||||
}
|
||||
_, _ = fmt.Fprintln(out)
|
||||
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 {
|
||||
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 {
|
||||
@@ -506,75 +615,34 @@ func (cli *Instance) ImportSecret(cmd *cobra.Command, secretName, sourceFile str
|
||||
return fmt.Errorf("failed to open file %s: %w", sourceFile, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
secret.Warn("Failed to close file", "error", err)
|
||||
closeErr := file.Close()
|
||||
if closeErr != nil {
|
||||
secret.Warn("Failed to close file", "error", closeErr)
|
||||
}
|
||||
}()
|
||||
|
||||
const initialSize = 4 * 1024 // 4KB initial buffer
|
||||
const maxSize = 100 * 1024 * 1024 // 100MB max
|
||||
buffers, totalSize, err := readSecretFromReader(file)
|
||||
if err != nil {
|
||||
if errors.Is(err, errSecretTooLarge) {
|
||||
return errSecretFileTooLarge
|
||||
}
|
||||
|
||||
type bufferInfo struct {
|
||||
buffer *memguard.LockedBuffer
|
||||
used int
|
||||
}
|
||||
|
||||
var buffers []bufferInfo
|
||||
defer func() {
|
||||
for _, b := range buffers {
|
||||
b.buffer.Destroy()
|
||||
}
|
||||
}()
|
||||
|
||||
totalSize := 0
|
||||
currentBufferSize := initialSize
|
||||
sameSize := 0
|
||||
|
||||
for {
|
||||
// Create a new buffer
|
||||
buffer := memguard.NewBuffer(currentBufferSize)
|
||||
n, err := io.ReadFull(file, 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 > maxSize {
|
||||
return fmt.Errorf("secret file too large: exceeds 100MB limit")
|
||||
}
|
||||
|
||||
// If we filled the buffer, consider growing for next iteration
|
||||
if n == currentBufferSize {
|
||||
currentBufferSize = updateBufferSize(currentBufferSize, &sameSize)
|
||||
}
|
||||
}
|
||||
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
|
||||
}
|
||||
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 := memguard.NewBuffer(totalSize)
|
||||
valueBuffer := combineBuffers(buffers, totalSize)
|
||||
defer valueBuffer.Destroy()
|
||||
|
||||
offset := 0
|
||||
for _, b := range buffers {
|
||||
copy(valueBuffer.Bytes()[offset:], b.buffer.Bytes()[:b.used])
|
||||
offset += b.used
|
||||
}
|
||||
|
||||
// Store the secret in the vault
|
||||
if err := vlt.AddSecret(secretName, valueBuffer, force); err != nil {
|
||||
err = vlt.AddSecret(secretName, valueBuffer, force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Successfully imported secret '%s' from file '%s'\n", secretName, sourceFile)
|
||||
cmd.Printf("Successfully imported secret '%s' from file '%s'\n",
|
||||
secretName, sourceFile)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -600,29 +668,36 @@ func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("secret '%s' not found", secretName)
|
||||
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
|
||||
}
|
||||
|
||||
// Count versions for information
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
versionCount := 0
|
||||
if entries, err := afero.ReadDir(cli.fs, versionsDir); err == nil {
|
||||
|
||||
entries, err := afero.ReadDir(cli.fs, versionsDir)
|
||||
if err == nil {
|
||||
versionCount = len(entries)
|
||||
}
|
||||
|
||||
// Remove the secret directory
|
||||
if err := cli.fs.RemoveAll(secretDir); err != nil {
|
||||
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)
|
||||
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 {
|
||||
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)
|
||||
@@ -634,25 +709,20 @@ func (cli *Instance) MoveSecret(cmd *cobra.Command, source, dest string, force b
|
||||
|
||||
// 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")
|
||||
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 new name
|
||||
// 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 {
|
||||
for _, v := range vaults {
|
||||
if v == dest {
|
||||
// dest is a vault name, use source secret name
|
||||
destVaultName = dest
|
||||
destSecretName = srcSecretName
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -670,7 +740,8 @@ func (cli *Instance) MoveSecret(cmd *cobra.Command, source, dest string, force b
|
||||
// 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 {
|
||||
err := vault.SelectVault(cli.fs, cli.stateDir, srcVaultName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select vault '%s': %w", srcVaultName, err)
|
||||
}
|
||||
|
||||
@@ -678,11 +749,14 @@ func (cli *Instance) MoveSecret(cmd *cobra.Command, source, dest string, force b
|
||||
}
|
||||
|
||||
// Cross-vault move
|
||||
return cli.moveSecretCrossVault(cmd, srcVaultName, srcSecretName, destVaultName, destSecretName, force)
|
||||
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 {
|
||||
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
|
||||
@@ -702,7 +776,7 @@ func (cli *Instance) moveSecretWithinVault(cmd *cobra.Command, source, dest stri
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("secret '%s' not found", source)
|
||||
return fmt.Errorf("secret '%s' %w", source, errSecretNotFound)
|
||||
}
|
||||
|
||||
destEncoded := strings.ReplaceAll(dest, "/", "%")
|
||||
@@ -715,15 +789,17 @@ func (cli *Instance) moveSecretWithinVault(cmd *cobra.Command, source, dest stri
|
||||
|
||||
if exists {
|
||||
if !force {
|
||||
return fmt.Errorf("secret '%s' already exists (use --force to overwrite)", dest)
|
||||
return fmt.Errorf("secret '%s' %w", dest, errSecretExistsNoForce)
|
||||
}
|
||||
|
||||
if err := cli.fs.RemoveAll(destDir); err != nil {
|
||||
err = cli.fs.RemoveAll(destDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove existing destination: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := cli.fs.Rename(sourceDir, destDir); err != nil {
|
||||
err = cli.fs.Rename(sourceDir, destDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to move secret: %w", err)
|
||||
}
|
||||
|
||||
@@ -741,8 +817,8 @@ func (cli *Instance) moveSecretCrossVault(
|
||||
) error {
|
||||
// Get source vault
|
||||
srcVault := vault.NewVault(cli.fs, cli.stateDir, srcVaultName)
|
||||
srcVaultDir, err := srcVault.GetDirectory()
|
||||
|
||||
srcVaultDir, err := srcVault.GetDirectory()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get source vault directory: %w", err)
|
||||
}
|
||||
@@ -750,7 +826,7 @@ func (cli *Instance) moveSecretCrossVault(
|
||||
// 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)
|
||||
return fmt.Errorf("source vault '%s' %w", srcVaultName, errVaultDoesNotExist)
|
||||
}
|
||||
|
||||
// Verify source secret exists
|
||||
@@ -759,13 +835,14 @@ func (cli *Instance) moveSecretCrossVault(
|
||||
|
||||
exists, err = afero.DirExists(cli.fs, srcSecretDir)
|
||||
if err != nil || !exists {
|
||||
return fmt.Errorf("secret '%s' not found in vault '%s'", srcSecretName, srcVaultName)
|
||||
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()
|
||||
|
||||
destVaultDir, err := destVault.GetDirectory()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
||||
}
|
||||
@@ -773,7 +850,8 @@ func (cli *Instance) moveSecretCrossVault(
|
||||
// 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)
|
||||
return fmt.Errorf("destination vault '%s' %w",
|
||||
destVaultName, errVaultDoesNotExist)
|
||||
}
|
||||
|
||||
// Unlock destination vault (will fail if neither mnemonic nor unlocker available)
|
||||
@@ -787,12 +865,15 @@ func (cli *Instance) moveSecretCrossVault(
|
||||
versionCount := len(versions)
|
||||
|
||||
// Copy all versions
|
||||
if err := destVault.CopySecretAllVersions(srcVault, srcSecretName, destSecretName, force); err != nil {
|
||||
err = destVault.CopySecretAllVersions(
|
||||
srcVault, srcSecretName, destSecretName, force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete source secret
|
||||
if err := cli.fs.RemoveAll(srcSecretDir); err != nil {
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user