Files
secret/internal/cli/version.go
T
clawbot 43f66bf369
check / check (push) Failing after 3s
Give each failure one error value (closes #113)
internal/cli drops its copies of vault.ErrSecretNotFound, ErrVaultNotFound,
ErrVersionNotFound and ErrSecretExists and of the secret package's keychain
and Secure Enclave errors, and its second error for an unknown unlocker
type, an invalid mnemonic, a length below 1, an unsupported secret type and
an oversized secret. vault.ErrNilValueBuffer becomes
secret.ErrNilValueBuffer. Every error of secret.ReadPassphrase wraps
ErrPassphraseNotRead. ResolveGPGKeyFingerprint returns ErrGPGKeyNotFound for
a key the keyring lacks. storeInKeychain returns errNilDataBuffer. bip85's
ErrPasswordTooShort and ErrEncodedTooShort go with their unreachable checks,
as does the macOS check in macOS-only code. Tests that matched these
errors' text use errors.Is.

Model: opus-5-5
2026-10-05 01:08:01 +02:00

411 lines
11 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 (
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 {
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
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. Asks for confirmation first; when stdin " +
"is not a terminal, fails unless --force is given.",
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 {
force, _ := cmd.Flags().GetBool("force")
return cli.RemoveVersion(cmd, args[0], args[1], force)
},
}
removeCmd.Flags().BoolP("force", "f", false,
"Remove without asking for confirmation")
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, vault.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 = ""
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// 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
}
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return err
}
defer release()
// 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
exists, err := secret.VersionExists(cli.fs, secretDir, version)
if err != nil {
return fmt.Errorf("failed to check if version exists: %w", err)
}
if !exists {
return fmt.Errorf("version '%s' %w '%s'",
version, vault.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, after asking the
// user to confirm unless force is set.
func (cli *Instance) RemoveVersion(
cmd *cobra.Command, secretName string, version string, force bool,
) error {
err := vault.ValidateSecretName(secretName)
if err != nil {
return err
}
var found versionToRemove
release, err := cli.askThenLock(cmd, force, func() (string, error) {
var err error
found, err = cli.findVersionToRemove(secretName, version)
return found.question, err
})
if err != nil {
return err
}
defer release()
err = secret.RemoveDirAtomic(cli.fs, found.dir)
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
}
// versionToRemove is what removing a version removes, as
// findVersionToRemove found it.
type versionToRemove struct {
// dir is the version's directory.
dir string
// question names what is removed, for the user to confirm.
question string
}
// findVersionToRemove checks that the version exists in the secret in the
// current vault and is not its current version.
func (cli *Instance) findVersionToRemove(
secretName, version string,
) (versionToRemove, error) {
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return versionToRemove{}, err
}
vaultDir, err := vlt.GetDirectory()
if err != nil {
return versionToRemove{}, 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 versionToRemove{},
fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
return versionToRemove{},
fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
}
// Check if version exists
exists, err = secret.VersionExists(cli.fs, secretDir, version)
if err != nil {
return versionToRemove{},
fmt.Errorf("failed to check if version exists: %w", err)
}
if !exists {
return versionToRemove{}, fmt.Errorf("version '%s' %w '%s'",
version, vault.ErrVersionNotFound, secretName)
}
// Get current version
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
if err != nil {
return versionToRemove{},
fmt.Errorf("failed to get current version: %w", err)
}
// Don't allow removing the current version
if version == currentVersion {
return versionToRemove{}, fmt.Errorf(
"cannot remove the current version '%s'; %w",
version, errCannotRemoveCurrentVersion)
}
return versionToRemove{
dir: filepath.Join(secretDir, "versions", version),
question: fmt.Sprintf("Permanently remove version %s of secret "+
"'%s' from vault '%s'?", version, secretName, vlt.GetName()),
}, nil
}