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 ", 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 ", 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 ", 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) // 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 { // 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 { // 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 }