check / check (push) Successful in 1m33s
version rm, version promote and get --version joined the version argument into a path unchecked, so "", ".", "..", "../../.." removed or read every version, the secret, the vault or directories above it. A version is now accepted only if it is one of the versions ListVersions lists for the secret, compared by name before any path is built (secret.VersionExists, used by all three). An empty --version is rejected instead of meaning the current version: GetSecretVersion no longer treats "" as current, and GetSecret looks the current version up itself. Model: opus-5-5
358 lines
9.3 KiB
Go
358 lines
9.3 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
|
|
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, 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
|
|
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, 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
|
|
versionDir := filepath.Join(secretDir, "versions", version)
|
|
|
|
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
|
|
}
|