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
360 lines
9.4 KiB
Go
360 lines
9.4 KiB
Go
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"path/filepath"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"filippo.io/age"
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"git.eeqj.de/sneak/secret/internal/vault"
|
|
"github.com/spf13/afero"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
const (
|
|
tabWriterPadding = 2
|
|
)
|
|
|
|
// Sentinel errors for version operations
|
|
var (
|
|
errVersionNotFound = errors.New("not found for secret")
|
|
errCannotRemoveCurrentVersion = errors.New("promote another version first")
|
|
)
|
|
|
|
// newVersionCmd returns the version management command
|
|
func newVersionCmd() *cobra.Command {
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
log.Fatalf("failed to initialize CLI: %v", err)
|
|
}
|
|
|
|
return VersionCommands(cli)
|
|
}
|
|
|
|
// VersionCommands returns the version management commands
|
|
func VersionCommands(cli *Instance) *cobra.Command {
|
|
versionCmd := &cobra.Command{
|
|
Use: "version",
|
|
Short: "Manage secret versions",
|
|
Long: "Commands for managing secret versions including listing, " +
|
|
"promoting, and retrieving specific versions",
|
|
}
|
|
|
|
// List versions command
|
|
listCmd := &cobra.Command{
|
|
Use: "list <secret-name>",
|
|
Aliases: []string{"ls"},
|
|
Short: "List all versions of a secret",
|
|
Args: cobra.ExactArgs(1),
|
|
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return cli.ListVersions(cmd, args[0])
|
|
},
|
|
}
|
|
|
|
// Promote version command
|
|
promoteCmd := &cobra.Command{
|
|
Use: "promote <secret-name> <version>",
|
|
Short: "Promote a specific version to current",
|
|
Long: "Updates the current symlink to point to the specified " +
|
|
"version without modifying timestamps",
|
|
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
|
|
ValidArgsFunction: func(
|
|
cmd *cobra.Command, args []string, toComplete string,
|
|
) ([]string, cobra.ShellCompDirective) {
|
|
// Complete secret name for first arg
|
|
if len(args) == 0 {
|
|
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
|
}
|
|
// Version number completion for the second arg is not implemented
|
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return cli.PromoteVersion(cmd, args[0], args[1])
|
|
},
|
|
}
|
|
|
|
// Remove version command
|
|
removeCmd := &cobra.Command{
|
|
Use: "remove <secret-name> <version>",
|
|
Aliases: []string{"rm"},
|
|
Short: "Remove a specific version of a secret",
|
|
Long: "Remove a specific version of a secret. Cannot remove the " +
|
|
"current version.",
|
|
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
|
|
ValidArgsFunction: func(
|
|
cmd *cobra.Command, args []string, toComplete string,
|
|
) ([]string, cobra.ShellCompDirective) {
|
|
// Complete secret name for first arg
|
|
if len(args) == 0 {
|
|
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
|
}
|
|
// Version number completion for the second arg is not implemented
|
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return cli.RemoveVersion(cmd, args[0], args[1])
|
|
},
|
|
}
|
|
|
|
versionCmd.AddCommand(listCmd, promoteCmd, removeCmd)
|
|
|
|
return versionCmd
|
|
}
|
|
|
|
// ListVersions lists all versions of a secret
|
|
func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
|
secret.Debug("ListVersions called", "secret_name", secretName)
|
|
|
|
err := vault.ValidateSecretName(secretName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
vaultDir, err := vlt.GetDirectory()
|
|
if err != nil {
|
|
secret.Debug("Failed to get vault directory", "error", err)
|
|
|
|
return err
|
|
}
|
|
|
|
// Get the encoded secret name
|
|
encodedName := strings.ReplaceAll(secretName, "/", "%")
|
|
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
|
|
|
|
// Check if secret exists
|
|
exists, err := afero.DirExists(cli.fs, secretDir)
|
|
if err != nil {
|
|
secret.Debug("Failed to check if secret exists", "error", err)
|
|
|
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
|
}
|
|
|
|
if !exists {
|
|
secret.Debug("Secret not found", "secret_name", secretName)
|
|
|
|
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
|
|
}
|
|
|
|
// List all versions
|
|
versions, err := secret.ListVersions(cli.fs, secretDir)
|
|
if err != nil {
|
|
secret.Debug("Failed to list versions", "error", err)
|
|
|
|
return fmt.Errorf("failed to list versions: %w", err)
|
|
}
|
|
|
|
if len(versions) == 0 {
|
|
cmd.Println("No versions found")
|
|
|
|
return nil
|
|
}
|
|
|
|
// Get current version
|
|
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
|
|
if err != nil {
|
|
secret.Debug("Failed to get current version", "error", err)
|
|
|
|
currentVersion = ""
|
|
}
|
|
|
|
// Get long-term key for decrypting metadata
|
|
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get long-term key: %w", err)
|
|
}
|
|
|
|
// Create table writer
|
|
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, tabWriterPadding, ' ', 0)
|
|
_, _ = fmt.Fprintln(w, "VERSION\tCREATED\tSTATUS\tNOT_BEFORE\tNOT_AFTER")
|
|
|
|
// Load and display each version's metadata
|
|
for _, version := range versions {
|
|
printVersionRow(w, vlt, secretName, version, currentVersion, ltIdentity)
|
|
}
|
|
|
|
_ = w.Flush()
|
|
|
|
return nil
|
|
}
|
|
|
|
// printVersionRow loads one version's metadata and writes its table row
|
|
func printVersionRow(
|
|
w io.Writer, vlt *vault.Vault,
|
|
secretName, version, currentVersion string,
|
|
ltIdentity *age.X25519Identity,
|
|
) {
|
|
sv := secret.NewVersion(vlt, secretName, version)
|
|
|
|
// Load metadata
|
|
err := sv.LoadMetadata(ltIdentity)
|
|
if err != nil {
|
|
secret.Warn("Failed to load version metadata",
|
|
"version", version, "error", err)
|
|
// Display version with error
|
|
status := "error"
|
|
if version == currentVersion {
|
|
status = "current (error)"
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
|
|
|
|
return
|
|
}
|
|
|
|
// Determine status
|
|
status := "expired"
|
|
if version == currentVersion {
|
|
status = "current"
|
|
}
|
|
|
|
// Format timestamps
|
|
createdAt := formatVersionTime(sv.Metadata.CreatedAt)
|
|
notBefore := formatVersionTime(sv.Metadata.NotBefore)
|
|
notAfter := formatVersionTime(sv.Metadata.NotAfter)
|
|
|
|
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
|
version, createdAt, status, notBefore, notAfter)
|
|
}
|
|
|
|
// formatVersionTime formats an optional version timestamp, "-" when unset
|
|
func formatVersionTime(t *time.Time) string {
|
|
if t == nil {
|
|
return "-"
|
|
}
|
|
|
|
return t.Format("2006-01-02 15:04:05")
|
|
}
|
|
|
|
// PromoteVersion promotes a specific version to current
|
|
func (cli *Instance) PromoteVersion(
|
|
cmd *cobra.Command, secretName string, version string,
|
|
) 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
|
|
}
|
|
|
|
vaultDir, err := vlt.GetDirectory()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get the encoded secret name
|
|
encodedName := strings.ReplaceAll(secretName, "/", "%")
|
|
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
|
|
|
|
// Check if version exists
|
|
versionDir := filepath.Join(secretDir, "versions", version)
|
|
|
|
exists, err := afero.DirExists(cli.fs, versionDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check if version exists: %w", err)
|
|
}
|
|
|
|
if !exists {
|
|
return fmt.Errorf("version '%s' %w '%s'",
|
|
version, errVersionNotFound, secretName)
|
|
}
|
|
|
|
// Update the current symlink using the proper function
|
|
err = secret.SetCurrentVersion(cli.fs, secretDir, version)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update current version: %w", err)
|
|
}
|
|
|
|
cmd.Printf("Promoted version %s to current for secret '%s'\n", version, secretName)
|
|
|
|
return nil
|
|
}
|
|
|
|
// RemoveVersion removes a specific version of a secret
|
|
func (cli *Instance) RemoveVersion(
|
|
cmd *cobra.Command, secretName string, version string,
|
|
) 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
|
|
}
|
|
|
|
vaultDir, err := vlt.GetDirectory()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get the encoded secret name
|
|
encodedName := strings.ReplaceAll(secretName, "/", "%")
|
|
secretDir := filepath.Join(vaultDir, "secrets.d", encodedName)
|
|
|
|
// Check if secret exists
|
|
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)
|
|
}
|
|
|
|
// Check if version exists
|
|
versionDir := filepath.Join(secretDir, "versions", version)
|
|
|
|
exists, err = afero.DirExists(cli.fs, versionDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check if version exists: %w", err)
|
|
}
|
|
|
|
if !exists {
|
|
return fmt.Errorf("version '%s' %w '%s'",
|
|
version, errVersionNotFound, secretName)
|
|
}
|
|
|
|
// Get current version
|
|
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get current version: %w", err)
|
|
}
|
|
|
|
// Don't allow removing the current version
|
|
if version == currentVersion {
|
|
return fmt.Errorf("cannot remove the current version '%s'; %w",
|
|
version, errCannotRemoveCurrentVersion)
|
|
}
|
|
|
|
// Remove the version directory
|
|
err = cli.fs.RemoveAll(versionDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove version: %w", err)
|
|
}
|
|
|
|
cmd.Printf("Removed version %s of secret '%s'\n", version, secretName)
|
|
|
|
return nil
|
|
}
|