257 lines
6.7 KiB
Go
257 lines
6.7 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"unicode/utf8"
|
|
|
|
"filippo.io/age"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var editCmd = &cobra.Command{
|
|
Use: "edit KEY[@STORE]",
|
|
Short: "Edit a key's value in $EDITOR",
|
|
Long: `Open a key's value in $EDITOR. If the key doesn't exist, opens an
|
|
empty file — saving non-empty content creates the key.
|
|
|
|
Binary values are presented as base64 for editing and decoded back on save.
|
|
|
|
Metadata flags (--ttl, --encrypt, --decrypt) can be passed alongside the edit
|
|
to modify metadata in the same operation.`,
|
|
Aliases: []string{"e"},
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: edit,
|
|
SilenceUsage: true,
|
|
}
|
|
|
|
func edit(cmd *cobra.Command, args []string) error {
|
|
editor := os.Getenv("EDITOR")
|
|
if editor == "" {
|
|
return withHint(
|
|
fmt.Errorf("EDITOR not set"),
|
|
"set $EDITOR to your preferred text editor",
|
|
)
|
|
}
|
|
|
|
store := &Store{}
|
|
|
|
spec, err := store.parseKey(args[0], true)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot edit '%s': %v", args[0], err)
|
|
}
|
|
|
|
ttlStr, _ := cmd.Flags().GetString("ttl")
|
|
encryptFlag, _ := cmd.Flags().GetBool("encrypt")
|
|
decryptFlag, _ := cmd.Flags().GetBool("decrypt")
|
|
preserveNewline, _ := cmd.Flags().GetBool("preserve-newline")
|
|
force, _ := cmd.Flags().GetBool("force")
|
|
readonlyFlag, _ := cmd.Flags().GetBool("readonly")
|
|
writableFlag, _ := cmd.Flags().GetBool("writable")
|
|
pinFlag, _ := cmd.Flags().GetBool("pin")
|
|
unpinFlag, _ := cmd.Flags().GetBool("unpin")
|
|
|
|
if encryptFlag && decryptFlag {
|
|
return fmt.Errorf("cannot edit '%s': --encrypt and --decrypt are mutually exclusive", args[0])
|
|
}
|
|
if readonlyFlag && writableFlag {
|
|
return fmt.Errorf("cannot edit '%s': --readonly and --writable are mutually exclusive", args[0])
|
|
}
|
|
if pinFlag && unpinFlag {
|
|
return fmt.Errorf("cannot edit '%s': --pin and --unpin are mutually exclusive", args[0])
|
|
}
|
|
|
|
// Load identity
|
|
var identity *age.X25519Identity
|
|
if encryptFlag {
|
|
identity, err = ensureIdentity()
|
|
if err != nil {
|
|
return fmt.Errorf("cannot edit '%s': %v", args[0], err)
|
|
}
|
|
} else {
|
|
identity, _ = loadIdentity()
|
|
}
|
|
recipients, err := allRecipients(identity)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot edit '%s': %v", args[0], err)
|
|
}
|
|
|
|
p, err := store.storePath(spec.DB)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot edit '%s': %v", args[0], err)
|
|
}
|
|
entries, err := readStoreFile(p, identity)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot edit '%s': %v", args[0], err)
|
|
}
|
|
idx := findEntry(entries, spec.Key)
|
|
|
|
creating := idx < 0
|
|
var original []byte
|
|
var wasBinary bool
|
|
var entry *Entry
|
|
|
|
if creating {
|
|
original = nil
|
|
} else {
|
|
entry = &entries[idx]
|
|
if entry.ReadOnly && !force {
|
|
return fmt.Errorf("cannot edit '%s': key is read-only", args[0])
|
|
}
|
|
if entry.Locked {
|
|
return fmt.Errorf("cannot edit '%s': secret is locked (identity file missing)", args[0])
|
|
}
|
|
original = entry.Value
|
|
wasBinary = !utf8.Valid(original)
|
|
}
|
|
|
|
// Prepare temp file content
|
|
var tmpContent []byte
|
|
if wasBinary {
|
|
tmpContent = []byte(base64.StdEncoding.EncodeToString(original))
|
|
} else {
|
|
tmpContent = original
|
|
}
|
|
|
|
// Write to temp file
|
|
tmpFile, err := os.CreateTemp("", "pda-edit-*")
|
|
if err != nil {
|
|
return fmt.Errorf("cannot edit '%s': %v", args[0], err)
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
defer os.Remove(tmpPath)
|
|
|
|
if _, err := tmpFile.Write(tmpContent); err != nil {
|
|
tmpFile.Close()
|
|
return fmt.Errorf("cannot edit '%s': %v", args[0], err)
|
|
}
|
|
if err := tmpFile.Close(); err != nil {
|
|
return fmt.Errorf("cannot edit '%s': %v", args[0], err)
|
|
}
|
|
|
|
// Launch editor
|
|
c := exec.Command(editor, tmpPath)
|
|
c.Stdin = os.Stdin
|
|
c.Stdout = os.Stdout
|
|
c.Stderr = os.Stderr
|
|
if err := c.Run(); err != nil {
|
|
return fmt.Errorf("cannot edit '%s': editor failed: %v", args[0], err)
|
|
}
|
|
|
|
// Read back
|
|
edited, err := os.ReadFile(tmpPath)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot edit '%s': %v", args[0], err)
|
|
}
|
|
|
|
// Decode base64 if original was binary; strip trailing newlines for text
|
|
// unless --preserve-newline is set
|
|
var newValue []byte
|
|
if wasBinary {
|
|
decoded, err := base64.StdEncoding.DecodeString(string(bytes.TrimSpace(edited)))
|
|
if err != nil {
|
|
return fmt.Errorf("cannot edit '%s': invalid base64: %v", args[0], err)
|
|
}
|
|
newValue = decoded
|
|
} else if preserveNewline {
|
|
newValue = edited
|
|
} else {
|
|
newValue = bytes.TrimRight(edited, "\n")
|
|
}
|
|
|
|
// Check for no-op
|
|
noMetaFlags := ttlStr == "" && !encryptFlag && !decryptFlag && !readonlyFlag && !writableFlag && !pinFlag && !unpinFlag
|
|
if bytes.Equal(original, newValue) && noMetaFlags {
|
|
infof("no changes to '%s'", spec.Display())
|
|
return nil
|
|
}
|
|
|
|
// Creating: empty save means abort
|
|
if creating && len(newValue) == 0 && noMetaFlags {
|
|
infof("empty value, nothing saved")
|
|
return nil
|
|
}
|
|
|
|
// Build or update entry
|
|
if creating {
|
|
newEntry := Entry{
|
|
Key: spec.Key,
|
|
Value: newValue,
|
|
Secret: encryptFlag,
|
|
ReadOnly: readonlyFlag,
|
|
Pinned: pinFlag,
|
|
}
|
|
if ttlStr != "" {
|
|
expiresAt, err := parseTTLString(ttlStr)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot edit '%s': %v", args[0], err)
|
|
}
|
|
newEntry.ExpiresAt = expiresAt
|
|
}
|
|
entries = append(entries, newEntry)
|
|
} else {
|
|
entry.Value = newValue
|
|
|
|
if ttlStr != "" {
|
|
expiresAt, err := parseTTLString(ttlStr)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot edit '%s': %v", args[0], err)
|
|
}
|
|
entry.ExpiresAt = expiresAt
|
|
}
|
|
|
|
if encryptFlag {
|
|
if entry.Secret {
|
|
return fmt.Errorf("cannot edit '%s': already encrypted", args[0])
|
|
}
|
|
entry.Secret = true
|
|
}
|
|
if decryptFlag {
|
|
if !entry.Secret {
|
|
return fmt.Errorf("cannot edit '%s': not encrypted", args[0])
|
|
}
|
|
entry.Secret = false
|
|
}
|
|
|
|
if readonlyFlag {
|
|
entry.ReadOnly = true
|
|
}
|
|
if writableFlag {
|
|
entry.ReadOnly = false
|
|
}
|
|
if pinFlag {
|
|
entry.Pinned = true
|
|
}
|
|
if unpinFlag {
|
|
entry.Pinned = false
|
|
}
|
|
}
|
|
|
|
if err := writeStoreFile(p, entries, recipients); err != nil {
|
|
return fmt.Errorf("cannot edit '%s': %v", args[0], err)
|
|
}
|
|
|
|
if creating {
|
|
okf("created '%s'", spec.Display())
|
|
} else {
|
|
okf("updated '%s'", spec.Display())
|
|
}
|
|
|
|
return autoSync("edit " + spec.Display())
|
|
}
|
|
|
|
func init() {
|
|
editCmd.Flags().String("ttl", "", "set expiry (e.g. 30m, 2h) or 'never' to clear")
|
|
editCmd.Flags().BoolP("encrypt", "e", false, "encrypt the value at rest")
|
|
editCmd.Flags().BoolP("decrypt", "d", false, "decrypt the value (store as plaintext)")
|
|
editCmd.Flags().Bool("preserve-newline", false, "keep trailing newlines added by the editor")
|
|
editCmd.Flags().Bool("force", false, "bypass read-only protection")
|
|
editCmd.Flags().Bool("readonly", false, "mark the key as read-only")
|
|
editCmd.Flags().Bool("writable", false, "clear the read-only flag")
|
|
editCmd.Flags().Bool("pin", false, "pin the key (sorts to top in list)")
|
|
editCmd.Flags().Bool("unpin", false, "unpin the key")
|
|
rootCmd.AddCommand(editCmd)
|
|
}
|