From 618842b2854f8b0cff9b4a6eabd09468d5f8f4e8 Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 13 Feb 2026 15:15:26 +0000 Subject: [PATCH] feat(meta): add meta command for viewing/modifying key metadata --- cmd/meta.go | 124 +++++++++++++++++++++++++++++++++++++++ cmd/root.go | 1 + testdata/meta-decrypt.ct | 9 +++ testdata/meta-encrypt.ct | 10 ++++ testdata/meta-err.ct | 21 +++++++ testdata/meta-ttl.ct | 11 ++++ testdata/meta.ct | 13 ++++ 7 files changed, 189 insertions(+) create mode 100644 cmd/meta.go create mode 100644 testdata/meta-decrypt.ct create mode 100644 testdata/meta-encrypt.ct create mode 100644 testdata/meta-err.ct create mode 100644 testdata/meta-ttl.ct create mode 100644 testdata/meta.ct diff --git a/cmd/meta.go b/cmd/meta.go new file mode 100644 index 0000000..0309649 --- /dev/null +++ b/cmd/meta.go @@ -0,0 +1,124 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var metaCmd = &cobra.Command{ + Use: "meta KEY[@STORE]", + Short: "View or modify metadata for a key", + Long: `View or modify metadata (TTL, encryption) for a key without changing its value. + +With no flags, displays the key's current metadata. Use flags to modify: + --ttl DURATION Set expiry (e.g. 30m, 2h) + --ttl never Remove expiry + --encrypt Encrypt the value at rest + --decrypt Decrypt the value (store as plaintext)`, + Args: cobra.ExactArgs(1), + RunE: meta, + SilenceUsage: true, +} + +func meta(cmd *cobra.Command, args []string) error { + store := &Store{} + + spec, err := store.parseKey(args[0], true) + if err != nil { + return fmt.Errorf("cannot meta '%s': %v", args[0], err) + } + + identity, _ := loadIdentity() + + p, err := store.storePath(spec.DB) + if err != nil { + return fmt.Errorf("cannot meta '%s': %v", args[0], err) + } + entries, err := readStoreFile(p, identity) + if err != nil { + return fmt.Errorf("cannot meta '%s': %v", args[0], err) + } + idx := findEntry(entries, spec.Key) + if idx < 0 { + keys := make([]string, len(entries)) + for i, e := range entries { + keys[i] = e.Key + } + return fmt.Errorf("cannot meta '%s': %w", args[0], suggestKey(spec.Key, keys)) + } + entry := &entries[idx] + + ttlStr, _ := cmd.Flags().GetString("ttl") + encryptFlag, _ := cmd.Flags().GetBool("encrypt") + decryptFlag, _ := cmd.Flags().GetBool("decrypt") + + if encryptFlag && decryptFlag { + return fmt.Errorf("cannot meta '%s': --encrypt and --decrypt are mutually exclusive", args[0]) + } + + // View mode: no flags set + if ttlStr == "" && !encryptFlag && !decryptFlag { + expiresStr := "never" + if entry.ExpiresAt > 0 { + expiresStr = formatExpiry(entry.ExpiresAt) + } + fmt.Fprintf(cmd.OutOrStdout(), " key: %s\n", spec.Full()) + fmt.Fprintf(cmd.OutOrStdout(), " secret: %v\n", entry.Secret) + fmt.Fprintf(cmd.OutOrStdout(), " expires: %s\n", expiresStr) + return nil + } + + // Modification mode — may need identity for encrypt + if encryptFlag { + identity, err = ensureIdentity() + if err != nil { + return fmt.Errorf("cannot meta '%s': %v", args[0], err) + } + } + recipients, err := allRecipients(identity) + if err != nil { + return fmt.Errorf("cannot meta '%s': %v", args[0], err) + } + + if ttlStr != "" { + expiresAt, err := parseTTLString(ttlStr) + if err != nil { + return fmt.Errorf("cannot meta '%s': %v", args[0], err) + } + entry.ExpiresAt = expiresAt + } + + if encryptFlag { + if entry.Secret { + return fmt.Errorf("cannot meta '%s': already encrypted", args[0]) + } + if entry.Locked { + return fmt.Errorf("cannot meta '%s': secret is locked (identity file missing)", args[0]) + } + entry.Secret = true + } + + if decryptFlag { + if !entry.Secret { + return fmt.Errorf("cannot meta '%s': not encrypted", args[0]) + } + if entry.Locked { + return fmt.Errorf("cannot meta '%s': secret is locked (identity file missing)", args[0]) + } + entry.Secret = false + } + + if err := writeStoreFile(p, entries, recipients); err != nil { + return fmt.Errorf("cannot meta '%s': %v", args[0], err) + } + + return autoSync("meta " + spec.Display()) +} + +func init() { + metaCmd.Flags().String("ttl", "", "set expiry (e.g. 30m, 2h) or 'never' to clear") + metaCmd.Flags().BoolP("encrypt", "e", false, "encrypt the value at rest") + metaCmd.Flags().BoolP("decrypt", "d", false, "decrypt the value (store as plaintext)") + rootCmd.AddCommand(metaCmd) +} diff --git a/cmd/root.go b/cmd/root.go index fa5416a..ba78a53 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -70,6 +70,7 @@ func init() { cpCmd.GroupID = "keys" delCmd.GroupID = "keys" listCmd.GroupID = "keys" + metaCmd.GroupID = "keys" identityCmd.GroupID = "keys" rootCmd.AddGroup(&cobra.Group{ID: "stores", Title: "Store commands:"}) diff --git a/testdata/meta-decrypt.ct b/testdata/meta-decrypt.ct new file mode 100644 index 0000000..6b9741c --- /dev/null +++ b/testdata/meta-decrypt.ct @@ -0,0 +1,9 @@ +# Decrypt an existing encrypted key +$ pda set --encrypt hello@md world +$ pda meta hello@md --decrypt +$ pda meta hello@md + key: hello@md + secret: false + expires: never +$ pda get hello@md +world diff --git a/testdata/meta-encrypt.ct b/testdata/meta-encrypt.ct new file mode 100644 index 0000000..f6898c3 --- /dev/null +++ b/testdata/meta-encrypt.ct @@ -0,0 +1,10 @@ +# Encrypt an existing plaintext key +$ pda set hello@me world +$ pda meta hello@me --encrypt +$ pda meta hello@me + key: hello@me + secret: true + expires: never +# Value should still be retrievable +$ pda get hello@me +world diff --git a/testdata/meta-err.ct b/testdata/meta-err.ct new file mode 100644 index 0000000..7f5cfba --- /dev/null +++ b/testdata/meta-err.ct @@ -0,0 +1,21 @@ +# Error: key doesn't exist +$ pda meta nonexistent@me --> FAIL +FAIL cannot meta 'nonexistent@me': no such key + +# Error: --encrypt and --decrypt are mutually exclusive +$ pda set hello@me world +$ pda meta hello@me --encrypt --decrypt --> FAIL +FAIL cannot meta 'hello@me': --encrypt and --decrypt are mutually exclusive + +# Error: already encrypted +$ pda set --encrypt secret@me val +$ pda meta secret@me --encrypt --> FAIL +FAIL cannot meta 'secret@me': already encrypted + +# Error: not encrypted (can't decrypt) +$ pda meta hello@me --decrypt --> FAIL +FAIL cannot meta 'hello@me': not encrypted + +# Error: invalid TTL +$ pda meta hello@me --ttl "abc" --> FAIL +FAIL cannot meta 'hello@me': invalid ttl '"abc"': expected a duration (e.g. 30m, 2h) or 'never' diff --git a/testdata/meta-ttl.ct b/testdata/meta-ttl.ct new file mode 100644 index 0000000..51fd761 --- /dev/null +++ b/testdata/meta-ttl.ct @@ -0,0 +1,11 @@ +# Set TTL on a key, then view it (just verify no error, can't match dynamic time) +$ pda set hello@mt world +$ pda meta hello@mt --ttl 1h + +# Clear TTL with --ttl never +$ pda set --ttl 1h expiring@mt val +$ pda meta expiring@mt --ttl never +$ pda meta expiring@mt + key: expiring@mt + secret: false + expires: never diff --git a/testdata/meta.ct b/testdata/meta.ct new file mode 100644 index 0000000..e13fcf9 --- /dev/null +++ b/testdata/meta.ct @@ -0,0 +1,13 @@ +# View metadata for a plaintext key +$ pda set hello@m world +$ pda meta hello@m + key: hello@m + secret: false + expires: never + +# View metadata for an encrypted key +$ pda set --encrypt secret@m hunter2 +$ pda meta secret@m + key: secret@m + secret: true + expires: never