Files
secret/internal/cli/version.go
T
sneak 526a6be17b
check / check (push) Successful in 1m13s
Reject invalid secret names before any command builds a path (closes #33)
`secret rm ..` resolved to the vault directory and deleted the whole
vault; `secret rm .` and `secret rm ""` deleted every secret. rm, mv and
import built paths from the name without checking it, and so did the
version commands, encrypt and decrypt.

vault.ValidateSecretName wraps the existing name rule and returns
ErrInvalidSecretName. Each of those commands calls it on the name as
given, both names for a move, before building any path. AddSecret and
GetSecretVersion use it too, so the rule has one implementation.

The regression test snapshots every file under the state directory of
two in-memory vaults and requires it unchanged after each rejected
command.

Model: opus-5-5
2026-10-03 12:31:35 +00:00

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
}