check / check (push) Failing after 3s
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
411 lines
11 KiB
Go
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
|
|
}
|