summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-17 08:45:13 +0300
committerPaul Buetow <paul@buetow.org>2026-04-17 08:45:13 +0300
commit9e2bf4af8b7b3b4ca2980aa6285482e7db0cd151 (patch)
tree96bcd8ad2a64e6dfc9d3bedc9d063eeef1591cf0
parentb53e348d89046ea8de5b82c283fb56980b53cdd8 (diff)
feat: add internal/keepass package with full Backend implementation (tasks k4+l4+m4)
Read-write KeePass backend implementing backend.Backend: - WalkIndexes flattens groups into 'Group/Title' virtual entries - LoadData returns formatted Password/User/URL/Notes for text entries or raw bytes for binary attachments - WriteBack hook enables edit round-trip through the kdbx file - Add/Import/ImportRecursive/Remove with atomic tmp+rename save - Binary attachments surface as virtual 'Group/Title/filename' entries; Add to such a path creates/replaces the attachment on the parent entry - AtomicSave exported for reuse by cli/kdbx_store.go - parseContent is the tolerant inverse of formatContent - Helpers EnsureGroup, UpsertEntryByTitle, SetEntryField, SplitDescriptionPath, SanitizeRelativePath exported so cli/kdbx_store.go avoids duplication Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--internal/keepass/attachments.go134
-rw-r--r--internal/keepass/entries.go215
-rw-r--r--internal/keepass/format.go96
-rw-r--r--internal/keepass/fzf.go94
-rw-r--r--internal/keepass/keepass.go256
-rw-r--r--internal/keepass/keepass_test.go176
-rw-r--r--internal/keepass/remove.go106
-rw-r--r--internal/keepass/search.go94
-rw-r--r--internal/keepass/write.go103
9 files changed, 1274 insertions, 0 deletions
diff --git a/internal/keepass/attachments.go b/internal/keepass/attachments.go
new file mode 100644
index 0000000..7defac1
--- /dev/null
+++ b/internal/keepass/attachments.go
@@ -0,0 +1,134 @@
+package keepass
+
+import (
+ "fmt"
+ "strings"
+
+ gokeepasslib "github.com/tobischo/gokeepasslib/v3"
+)
+
+// isAttachmentPath reports whether description refers to a virtual attachment
+// entry. It returns true when the parent path (description minus the last
+// component) matches an existing text entry and the description itself does
+// not match any existing entry.
+//
+// This implements the "Add to .../filename creates attachment on parent entry"
+// contract: if Group/Title exists and Group/Title/file.bin does not, the last
+// component is treated as an attachment filename.
+func (b *Backend) isAttachmentPath(description string) (parentDesc, attachName string, ok bool) {
+ // An attachment path must have at least two components (parent + filename).
+ lastSlash := strings.LastIndex(description, "/")
+ if lastSlash < 0 {
+ return "", "", false
+ }
+
+ parentDesc = description[:lastSlash]
+ attachName = description[lastSlash+1:]
+ if attachName == "" || parentDesc == "" {
+ return "", "", false
+ }
+
+ // The path itself must not already be a standalone text entry —
+ // if it is, treat it as a normal entry update.
+ if b.isTextEntry(description) {
+ return "", "", false
+ }
+
+ // The parent must exist as a text (non-attachment) entry.
+ if !b.isTextEntry(parentDesc) {
+ return "", "", false
+ }
+
+ return parentDesc, attachName, true
+}
+
+// isTextEntry reports whether description refers to an existing non-attachment
+// virtual entry in the database.
+func (b *Backend) isTextEntry(description string) bool {
+ for _, ve := range walkEntries(b.root()) {
+ if ve.description == description && !ve.isBinary {
+ return true
+ }
+ }
+ return false
+}
+
+// addAttachment creates or replaces a binary attachment named attachName on the
+// entry identified by parentDesc. The parent entry must already exist; use
+// Add for the parent before adding an attachment to it.
+func (b *Backend) addAttachment(parentDesc, attachName string, content []byte) error {
+ groupPath, title, err := SplitDescriptionPath(parentDesc)
+ if err != nil {
+ return fmt.Errorf("keepass add attachment: %w", err)
+ }
+
+ g := EnsureGroup(b.root(), groupPath)
+ entry, found := findEntryByTitle(g, title)
+ if !found {
+ return fmt.Errorf("keepass add attachment: parent entry %q not found", parentDesc)
+ }
+
+ upsertAttachment(b.db, entry, attachName, content)
+ return b.save()
+}
+
+// findEntryByTitle locates an entry by title inside g without creating a new
+// one. Returns the entry pointer and true when found.
+func findEntryByTitle(g *gokeepasslib.Group, title string) (*gokeepasslib.Entry, bool) {
+ for i := range g.Entries {
+ if g.Entries[i].GetTitle() == title {
+ return &g.Entries[i], true
+ }
+ }
+ return nil, false
+}
+
+// upsertAttachment replaces an existing attachment named name on entry, or
+// appends a new one if no attachment with that name exists.
+func upsertAttachment(db *gokeepasslib.Database, entry *gokeepasslib.Entry, name string, content []byte) {
+ // Remove the existing attachment reference for this name, if any.
+ // The old binary in the db-level pool is orphaned (gokeepasslib handles
+ // pool cleanup on encode), so we only need to remove the reference.
+ newRefs := entry.Binaries[:0]
+ for _, ref := range entry.Binaries {
+ if ref.Name != name {
+ newRefs = append(newRefs, ref)
+ }
+ }
+ entry.Binaries = newRefs
+
+ // Add the new binary to the pool and reference it from the entry.
+ bin := db.AddBinary(content)
+ entry.Binaries = append(entry.Binaries, bin.CreateReference(name))
+}
+
+// removeAttachment removes a binary attachment named attachName from the entry
+// identified by parentDesc. Returns an error if the parent entry or attachment
+// is not found.
+func (b *Backend) removeAttachment(parentDesc, attachName string) error {
+ groupPath, title, err := SplitDescriptionPath(parentDesc)
+ if err != nil {
+ return fmt.Errorf("keepass remove attachment: %w", err)
+ }
+
+ g := EnsureGroup(b.root(), groupPath)
+ entry, found := findEntryByTitle(g, title)
+ if !found {
+ return fmt.Errorf("keepass remove attachment: parent entry %q not found", parentDesc)
+ }
+
+ before := len(entry.Binaries)
+ newRefs := entry.Binaries[:0]
+ for _, ref := range entry.Binaries {
+ if ref.Name != attachName {
+ newRefs = append(newRefs, ref)
+ }
+ }
+ entry.Binaries = newRefs
+
+ if len(entry.Binaries) == before {
+ return fmt.Errorf("keepass remove attachment: attachment %q not found on %q", attachName, parentDesc)
+ }
+
+ return b.save()
+}
diff --git a/internal/keepass/entries.go b/internal/keepass/entries.go
new file mode 100644
index 0000000..5ad1254
--- /dev/null
+++ b/internal/keepass/entries.go
@@ -0,0 +1,215 @@
+package keepass
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ gokeepasslib "github.com/tobischo/gokeepasslib/v3"
+
+ "codeberg.org/snonux/foostore/internal/store"
+)
+
+// virtualEntry is an in-memory row produced by flattening the KeePass group
+// tree. Each row corresponds to either a text entry or a binary attachment.
+type virtualEntry struct {
+ // description is the foostore-style "Group/Title" or "Group/Title/filename"
+ // that uniquely identifies this entry across the flattened view.
+ description string
+ // isBinary is true for attachment rows; their content is raw bytes
+ // rather than formatted text.
+ isBinary bool
+ // entry is the underlying KeePass entry. For binary rows this is the
+ // parent entry; for text rows it is the entry itself.
+ entry *gokeepasslib.Entry
+ // attachmentName is non-empty only for binary attachment rows.
+ attachmentName string
+}
+
+// toIndex converts a virtualEntry into a *store.Index using a SHA-256 of the
+// description as the Hash field. This gives fzf key stability across restarts
+// without requiring any on-disk index file.
+func (v *virtualEntry) toIndex() *store.Index {
+ sum := sha256.Sum256([]byte(v.description))
+ hash := hex.EncodeToString(sum[:])
+ return &store.Index{
+ Description: v.description,
+ Hash: hash,
+ }
+}
+
+// walkEntries flattens the entire KeePass group tree starting from root into
+// a slice of virtualEntry rows. Groups are traversed depth-first; within each
+// group, entries come before sub-groups. Binary attachments surface as extra
+// virtual entries "Group/Title/filename" after their parent text entry.
+func walkEntries(root *gokeepasslib.Group) []virtualEntry {
+ var rows []virtualEntry
+ collectGroup(&rows, root, nil)
+ return rows
+}
+
+// collectGroup recursively collects entries from g and its sub-groups.
+// groupPath holds the ancestor group names (not including g itself).
+func collectGroup(rows *[]virtualEntry, g *gokeepasslib.Group, groupPath []string) {
+ // Include the current group's name in the path passed to children,
+ // but skip the synthetic "Root" group at the top level so descriptions
+ // don't start with "Root/".
+ var myPath []string
+ if g.Name != "" && g.Name != "Root" {
+ myPath = append(groupPath, g.Name)
+ } else {
+ myPath = groupPath
+ }
+
+ for i := range g.Entries {
+ collectEntry(rows, &g.Entries[i], myPath)
+ }
+ for i := range g.Groups {
+ collectGroup(rows, &g.Groups[i], myPath)
+ }
+}
+
+// collectEntry adds one text virtualEntry for e plus one virtualEntry per
+// binary attachment found on e.
+func collectEntry(rows *[]virtualEntry, e *gokeepasslib.Entry, groupPath []string) {
+ title := e.GetTitle()
+ if title == "" {
+ title = "(untitled)"
+ }
+ desc := descriptionOf(groupPath, title, "")
+ *rows = append(*rows, virtualEntry{
+ description: desc,
+ isBinary: false,
+ entry: e,
+ })
+
+ for _, binRef := range e.Binaries {
+ attName := binRef.Name
+ if attName == "" {
+ attName = "attachment"
+ }
+ *rows = append(*rows, virtualEntry{
+ description: descriptionOf(groupPath, title, attName),
+ isBinary: true,
+ entry: e,
+ attachmentName: attName,
+ })
+ }
+}
+
+// descriptionOf builds the foostore Description string from the group path
+// components, an entry title, and an optional attachment filename.
+// If attachmentName is empty, the result is "Group/Title";
+// otherwise "Group/Title/attachmentName".
+func descriptionOf(groupPath []string, title, attachmentName string) string {
+ parts := make([]string, 0, len(groupPath)+2)
+ parts = append(parts, groupPath...)
+ parts = append(parts, title)
+ if attachmentName != "" {
+ parts = append(parts, attachmentName)
+ }
+ return strings.Join(parts, "/")
+}
+
+// getEntryField returns the value of the named field from an entry, or "".
+func getEntryField(e *gokeepasslib.Entry, key string) string {
+ for _, v := range e.Values {
+ if v.Key == key {
+ return v.Value.Content
+ }
+ }
+ return ""
+}
+
+// SetEntryField sets key=value on entry, updating in place if the key already
+// exists or appending a new ValueData otherwise. Protected fields use the plain
+// V struct; callers that need protection must set Value.Protected separately.
+// Exported so that internal/cli/kdbx_store.go can reuse it without duplication.
+func SetEntryField(entry *gokeepasslib.Entry, key, value string) {
+ for i := range entry.Values {
+ if entry.Values[i].Key == key {
+ entry.Values[i].Value.Content = value
+ return
+ }
+ }
+ entry.Values = append(entry.Values, gokeepasslib.ValueData{
+ Key: key,
+ Value: gokeepasslib.V{Content: value},
+ })
+}
+
+// EnsureGroup traverses the group tree from root following groupPath, creating
+// sub-groups that do not yet exist. It returns a pointer to the leaf group.
+// Exported so that internal/cli/kdbx_store.go can reuse it without duplication.
+func EnsureGroup(root *gokeepasslib.Group, groupPath []string) *gokeepasslib.Group {
+ g := root
+ for _, segment := range groupPath {
+ if segment == "" {
+ continue
+ }
+ found := -1
+ for i := range g.Groups {
+ if g.Groups[i].Name == segment {
+ found = i
+ break
+ }
+ }
+ if found == -1 {
+ ng := gokeepasslib.NewGroup()
+ ng.Name = segment
+ g.Groups = append(g.Groups, ng)
+ found = len(g.Groups) - 1
+ }
+ g = &g.Groups[found]
+ }
+ return g
+}
+
+// UpsertEntryByTitle finds an existing entry with the given title inside g, or
+// appends a new blank entry. Returns a pointer to the entry and true if it was
+// an update (title already existed).
+// Exported so that internal/cli/kdbx_store.go can reuse it without duplication.
+func UpsertEntryByTitle(g *gokeepasslib.Group, title string) (*gokeepasslib.Entry, bool) {
+ for i := range g.Entries {
+ if g.Entries[i].GetTitle() == title {
+ return &g.Entries[i], true
+ }
+ }
+ e := gokeepasslib.NewEntry()
+ g.Entries = append(g.Entries, e)
+ return &g.Entries[len(g.Entries)-1], false
+}
+
+// SplitDescriptionPath splits a foostore description ("Group/Title") into a
+// group-path slice and a title, normalising and validating the path first.
+// Exported so that internal/cli/kdbx_store.go can reuse it without duplication.
+func SplitDescriptionPath(description string) ([]string, string, error) {
+ safePath, err := SanitizeRelativePath(description)
+ if err != nil {
+ return nil, "", err
+ }
+ parts := strings.Split(safePath, "/")
+ if len(parts) == 1 {
+ return nil, parts[0], nil
+ }
+ return parts[:len(parts)-1], parts[len(parts)-1], nil
+}
+
+// SanitizeRelativePath normalises slashes, trims whitespace, and rejects paths
+// that would escape the store root (empty, ".", "..", or starting with "../").
+// Exported so that internal/cli/kdbx_store.go can reuse it without duplication.
+func SanitizeRelativePath(path string) (string, error) {
+ normalised := strings.ReplaceAll(path, "\\", "/")
+ normalised = strings.TrimSpace(normalised)
+ if normalised == "" {
+ return "", fmt.Errorf("empty entry description")
+ }
+ clean := filepath.Clean(normalised)
+ clean = strings.TrimPrefix(clean, "/")
+ if clean == "." || clean == "" || clean == ".." || strings.HasPrefix(clean, "../") {
+ return "", fmt.Errorf("unsafe entry description path %q", path)
+ }
+ return clean, nil
+}
diff --git a/internal/keepass/format.go b/internal/keepass/format.go
new file mode 100644
index 0000000..98f5af1
--- /dev/null
+++ b/internal/keepass/format.go
@@ -0,0 +1,96 @@
+// Package keepass provides a read-only backend.Backend implementation that
+// reads secrets from a KeePass (.kdbx) database. It flattens KeePass groups
+// into Description="Group/Subgroup/Title" entries and formats content as
+// "Password:...\nUser:...\nURL:...\nNotes:\n..." text.
+package keepass
+
+import (
+ "regexp"
+ "strings"
+)
+
+// Content field patterns — compiled once at package init to avoid
+// per-call regex allocation.
+var (
+ passwordPattern = regexp.MustCompile(`(?i)^\s*pass(?:word)?\s*:\s*(.*)\s*$`)
+ userPattern = regexp.MustCompile(`(?i)^\s*user(?:name)?\s*:\s*(.*)\s*$`)
+ urlPattern = regexp.MustCompile(`(?i)^\s*url\s*:\s*(.*)\s*$`)
+ // notesPattern matches the "Notes:" header with nothing after the colon.
+ // This is intentional: formatContent always emits "Notes:\n" on its own
+ // line so that everything following the header is treated as multi-line
+ // notes body. A "Notes: value" form on the same line is not produced by
+ // formatContent and would be silently ignored during parseContent; that
+ // case is explicitly unsupported to keep the parser simple.
+ notesPattern = regexp.MustCompile(`(?i)^\s*notes\s*:\s*$`)
+)
+
+// formatContent builds the canonical multi-line text representation for a
+// KeePass entry. The stable field order (Password / User / URL / Notes)
+// ensures round-trips through parseContent are lossless.
+//
+// Output format:
+//
+// Password: <value>
+// User: <value>
+// URL: <value>
+// Notes:
+// <notes lines>
+func formatContent(password, user, url, notes string) []byte {
+ var b strings.Builder
+ b.WriteString("Password: ")
+ b.WriteString(password)
+ b.WriteByte('\n')
+ b.WriteString("User: ")
+ b.WriteString(user)
+ b.WriteByte('\n')
+ b.WriteString("URL: ")
+ b.WriteString(url)
+ b.WriteByte('\n')
+ b.WriteString("Notes:\n")
+ if notes != "" {
+ b.WriteString(notes)
+ if !strings.HasSuffix(notes, "\n") {
+ b.WriteByte('\n')
+ }
+ }
+ return []byte(b.String())
+}
+
+// parseContent is the inverse of formatContent. It tolerates missing or
+// reordered fields and handles a multi-line Notes section. Lines that appear
+// before a "Notes:" header are matched against the password/user/url patterns;
+// everything after "Notes:" is collected verbatim.
+//
+// This generalises extractPasswordFromContent from internal/cli/migrate_kdbx.go
+// to also handle User: and URL: fields.
+func parseContent(content []byte) (password, user, url, notes string) {
+ lines := strings.Split(string(content), "\n")
+ inNotes := false
+ var notesLines []string
+
+ for _, line := range lines {
+ if inNotes {
+ notesLines = append(notesLines, line)
+ continue
+ }
+ if notesPattern.MatchString(line) {
+ inNotes = true
+ continue
+ }
+ if m := passwordPattern.FindStringSubmatch(line); len(m) == 2 && password == "" {
+ password = strings.TrimSpace(m[1])
+ continue
+ }
+ if m := userPattern.FindStringSubmatch(line); len(m) == 2 && user == "" {
+ user = strings.TrimSpace(m[1])
+ continue
+ }
+ if m := urlPattern.FindStringSubmatch(line); len(m) == 2 && url == "" {
+ url = strings.TrimSpace(m[1])
+ continue
+ }
+ }
+
+ notes = strings.TrimRight(strings.Join(notesLines, "\n"), "\n")
+ return password, user, url, notes
+}
diff --git a/internal/keepass/fzf.go b/internal/keepass/fzf.go
new file mode 100644
index 0000000..a30e769
--- /dev/null
+++ b/internal/keepass/fzf.go
@@ -0,0 +1,94 @@
+package keepass
+
+import (
+ "context"
+ "sort"
+ "strings"
+
+ "codeberg.org/snonux/foostore/internal/picker"
+ "codeberg.org/snonux/foostore/internal/store"
+)
+
+// Fzf launches fzf and returns only the selected description.
+func (b *Backend) Fzf(ctx context.Context) (string, error) {
+ result, err := b.FzfInteractive(ctx)
+ if err != nil {
+ return "", err
+ }
+ return result.Description, nil
+}
+
+// FzfInteractive launches fzf with action key bindings and returns the
+// selected description plus the chosen action.
+func (b *Backend) FzfInteractive(ctx context.Context) (store.PickerResult, error) {
+ var indexes store.IndexSlice
+ if err := b.WalkIndexes(ctx, "", func(idx *store.Index) error {
+ indexes = append(indexes, idx)
+ return nil
+ }); err != nil {
+ return store.PickerResult{}, err
+ }
+ if len(indexes) == 0 {
+ return store.PickerResult{}, nil
+ }
+
+ sort.Sort(indexes)
+ entries := buildPickerEntries(indexes)
+ return runFzfInteractive(ctx, entries)
+}
+
+// buildPickerEntries converts a sorted IndexSlice into picker.Entry rows.
+func buildPickerEntries(indexes store.IndexSlice) []picker.Entry {
+ entries := make([]picker.Entry, 0, len(indexes))
+ for i, idx := range indexes {
+ kind := "TEXT"
+ if idx.IsBinary() {
+ kind = "BINARY"
+ }
+ hashSuffix := ""
+ if len(idx.Hash) >= 63 {
+ hashSuffix = idx.Hash[53:63]
+ }
+ entries = append(entries, picker.Entry{
+ RowID: i + 1,
+ Description: idx.Description,
+ Kind: kind,
+ HashSuffix: hashSuffix,
+ })
+ }
+ return entries
+}
+
+// runFzfInteractive calls picker.Run and maps the chosen key to a PickerAction.
+func runFzfInteractive(ctx context.Context, entries []picker.Entry) (store.PickerResult, error) {
+ selection, err := picker.Run(ctx, entries)
+ if err != nil {
+ return store.PickerResult{}, err
+ }
+ action, ok := parsePickerAction(selection.Key)
+ if !ok || selection.Description == "" {
+ return store.PickerResult{}, nil
+ }
+ return store.PickerResult{
+ Description: selection.Description,
+ Action: action,
+ }, nil
+}
+
+// parsePickerAction maps an fzf key string to a PickerAction.
+func parsePickerAction(keyLine string) (store.PickerAction, bool) {
+ switch strings.TrimSpace(keyLine) {
+ case "", "enter":
+ return store.PickerSelect, true
+ case "ctrl-t", "alt-t":
+ return store.PickerCat, true
+ case "ctrl-y", "alt-y":
+ return store.PickerPaste, true
+ case "ctrl-o", "alt-o":
+ return store.PickerOpen, true
+ case "ctrl-e", "alt-e":
+ return store.PickerEdit, true
+ default:
+ return "", false
+ }
+}
diff --git a/internal/keepass/keepass.go b/internal/keepass/keepass.go
new file mode 100644
index 0000000..dedf302
--- /dev/null
+++ b/internal/keepass/keepass.go
@@ -0,0 +1,256 @@
+package keepass
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "regexp"
+
+ gokeepasslib "github.com/tobischo/gokeepasslib/v3"
+
+ "codeberg.org/snonux/foostore/internal/backend"
+ "codeberg.org/snonux/foostore/internal/config"
+ "codeberg.org/snonux/foostore/internal/store"
+)
+
+// Backend implements backend.Backend for a KeePass (.kdbx) database.
+// It is intentionally read-oriented; write methods (Add, Import, etc.) are
+// currently stubs that return "not supported" errors — they will be wired in a
+// later task once the read path is validated. The database is loaded once at
+// construction time and held in memory.
+type Backend struct {
+ cfg *config.Config
+ db *gokeepasslib.Database
+ dbPath string
+ regexCache map[string]*regexp.Regexp
+}
+
+// Compile-time assertion: *Backend must satisfy backend.Backend.
+var _ backend.Backend = (*Backend)(nil)
+
+// New opens the KeePass database at cfg.KDBXPath using the supplied password
+// and optional key-file bytes. The database is fully decrypted and held in
+// memory; the file is closed immediately after loading.
+//
+// Pass a non-nil keyFileData only when cfg.KDBXKeyFile is non-empty; otherwise
+// pass nil to use password-only credentials.
+func New(cfg *config.Config, password string, keyFileData []byte) (*Backend, error) {
+ creds, err := buildCredentials(password, keyFileData)
+ if err != nil {
+ return nil, fmt.Errorf("building keepass credentials: %w", err)
+ }
+
+ db, err := openDatabase(cfg.KDBXPath, creds)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Backend{
+ cfg: cfg,
+ db: db,
+ dbPath: cfg.KDBXPath,
+ regexCache: make(map[string]*regexp.Regexp),
+ }, nil
+}
+
+// buildCredentials returns the appropriate gokeepasslib credentials pointer.
+// When keyFileData is non-empty a combined password+keyfile credential is used;
+// otherwise a password-only credential is returned.
+func buildCredentials(password string, keyFileData []byte) (*gokeepasslib.DBCredentials, error) {
+ if len(keyFileData) > 0 {
+ return gokeepasslib.NewPasswordAndKeyDataCredentials(password, keyFileData)
+ }
+ return gokeepasslib.NewPasswordCredentials(password), nil
+}
+
+// openDatabase opens, decodes, and unlocks a KeePass database from the given
+// path using the supplied credentials.
+func openDatabase(path string, creds *gokeepasslib.DBCredentials) (*gokeepasslib.Database, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("opening kdbx %q: %w", path, err)
+ }
+ defer f.Close()
+
+ db := gokeepasslib.NewDatabase()
+ db.Credentials = creds
+ if err := gokeepasslib.NewDecoder(f).Decode(db); err != nil {
+ return nil, fmt.Errorf("decoding kdbx %q: %w", path, err)
+ }
+ if err := db.UnlockProtectedEntries(); err != nil {
+ return nil, fmt.Errorf("unlocking kdbx %q: %w", path, err)
+ }
+ ensureRootGroup(db)
+ return db, nil
+}
+
+// ensureRootGroup guarantees that the database has a Content, Root, and at
+// least one top-level group so that all navigation helpers can assume a valid
+// tree structure.
+func ensureRootGroup(db *gokeepasslib.Database) {
+ if db.Content == nil {
+ db.Content = gokeepasslib.NewContent()
+ }
+ if db.Content.Root == nil {
+ db.Content.Root = gokeepasslib.NewRootData()
+ }
+ if len(db.Content.Root.Groups) == 0 {
+ root := gokeepasslib.NewGroup()
+ root.Name = "Root"
+ db.Content.Root.Groups = append(db.Content.Root.Groups, root)
+ }
+}
+
+// root returns the single top-level KeePass group that acts as the tree root.
+func (b *Backend) root() *gokeepasslib.Group {
+ return &b.db.Content.Root.Groups[0]
+}
+
+// WalkIndexes iterates over every virtual entry in the database whose
+// description matches searchTerm (empty matches all) and calls fn for each.
+func (b *Backend) WalkIndexes(ctx context.Context, searchTerm string, fn func(*store.Index) error) error {
+ regex, err := b.compileRegex(searchTerm)
+ if err != nil {
+ return err
+ }
+
+ for _, ve := range walkEntries(b.root()) {
+ if searchTerm != "" && !regex.MatchString(ve.description) {
+ continue
+ }
+ if err := fn(ve.toIndex()); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// compileRegex returns a cached compiled regexp for the given search term.
+func (b *Backend) compileRegex(searchTerm string) (*regexp.Regexp, error) {
+ if r, ok := b.regexCache[searchTerm]; ok {
+ return r, nil
+ }
+ r, err := regexp.Compile(searchTerm)
+ if err != nil {
+ return nil, fmt.Errorf("invalid search term %q: %w", searchTerm, err)
+ }
+ b.regexCache[searchTerm] = r
+ return r, nil
+}
+
+// LoadData builds a *store.Data for the given index entry. For text entries
+// the Content is the formatted Password/User/URL/Notes block; for binary
+// attachment entries the Content is the raw attachment bytes.
+//
+// WriteBack is populated so that edits via ReimportAfterExport parse the
+// updated text back into KeePass fields and persist the database.
+func (b *Backend) LoadData(ctx context.Context, idx *store.Index) (*store.Data, error) {
+ for _, ve := range walkEntries(b.root()) {
+ if ve.description != idx.Description {
+ continue
+ }
+ return b.virtualEntryToData(ctx, &ve)
+ }
+ return nil, fmt.Errorf("keepass: entry %q not found", idx.Description)
+}
+
+// virtualEntryToData converts a resolved virtualEntry into a *store.Data.
+func (b *Backend) virtualEntryToData(ctx context.Context, ve *virtualEntry) (*store.Data, error) {
+ if ve.isBinary {
+ return b.binaryData(ve)
+ }
+ return b.textData(ve)
+}
+
+// textData builds a *store.Data for a text (non-attachment) virtual entry.
+func (b *Backend) textData(ve *virtualEntry) (*store.Data, error) {
+ password := getEntryField(ve.entry, "Password")
+ user := getEntryField(ve.entry, "UserName")
+ url := getEntryField(ve.entry, "URL")
+ notes := getEntryField(ve.entry, "Notes")
+ content := formatContent(password, user, url, notes)
+
+ d := &store.Data{Content: content}
+ d.WriteBack = b.makeWriteBack(ve.description)
+ return d, nil
+}
+
+// binaryData builds a *store.Data for a binary attachment virtual entry.
+// It resolves the BinaryReference against the database-level binaries store
+// and returns the decompressed content bytes.
+func (b *Backend) binaryData(ve *virtualEntry) (*store.Data, error) {
+ for _, binRef := range ve.entry.Binaries {
+ if binRef.Name != ve.attachmentName {
+ continue
+ }
+ bin := binRef.Find(b.db)
+ if bin == nil {
+ return nil, fmt.Errorf("keepass: binary ID %d not found in db", binRef.Value.ID)
+ }
+ content, err := bin.GetContentBytes()
+ if err != nil {
+ return nil, fmt.Errorf("keepass: reading attachment %q: %w", ve.attachmentName, err)
+ }
+ return &store.Data{Content: content}, nil
+ }
+ return nil, fmt.Errorf("keepass: attachment %q not found on entry %q", ve.attachmentName, ve.description)
+}
+
+// makeWriteBack returns a WriteBack function that parses updated text content
+// back into KeePass fields and saves the database. The d *store.Data parameter
+// is intentionally absent — the closure only needs the description and db ref.
+func (b *Backend) makeWriteBack(description string) func([]byte) error {
+ return func(newContent []byte) error {
+ password, user, url, notes := parseContent(newContent)
+ groupPath, title, err := SplitDescriptionPath(description)
+ if err != nil {
+ return fmt.Errorf("keepass writeback: %w", err)
+ }
+ g := EnsureGroup(b.root(), groupPath)
+ entry, _ := UpsertEntryByTitle(g, title)
+ SetEntryField(entry, "Title", title)
+ SetEntryField(entry, "Password", password)
+ SetEntryField(entry, "UserName", user)
+ SetEntryField(entry, "URL", url)
+ SetEntryField(entry, "Notes", notes)
+ return b.save()
+ }
+}
+
+// save locks protected entries and atomically replaces the database file.
+func (b *Backend) save() error {
+ if err := b.db.LockProtectedEntries(); err != nil {
+ return fmt.Errorf("keepass: locking entries: %w", err)
+ }
+ defer func() {
+ // Re-unlock so in-memory state stays usable after a save.
+ _ = b.db.UnlockProtectedEntries()
+ }()
+ return AtomicSave(b.db, b.dbPath)
+}
+
+// AtomicSave encodes db to a temporary file then renames it over dbPath,
+// ensuring the database file is never left in a partial state.
+// The file is closed exactly once via the explicit close below; no defer is
+// used to avoid double-close on the already-closed file handle.
+// Exported so that cli.kdbxStore.Save() can reuse the same pattern without
+// duplicating the tmp→rename logic.
+func AtomicSave(db *gokeepasslib.Database, dbPath string) error {
+ tmpPath := dbPath + ".tmp"
+ out, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
+ if err != nil {
+ return fmt.Errorf("keepass: creating tmp file %q: %w", tmpPath, err)
+ }
+
+ if err := gokeepasslib.NewEncoder(out).Encode(db); err != nil {
+ _ = out.Close()
+ return fmt.Errorf("keepass: encoding to %q: %w", tmpPath, err)
+ }
+ if err := out.Close(); err != nil {
+ return fmt.Errorf("keepass: closing tmp file %q: %w", tmpPath, err)
+ }
+ if err := os.Rename(tmpPath, dbPath); err != nil {
+ return fmt.Errorf("keepass: replacing db %q: %w", dbPath, err)
+ }
+ return nil
+}
diff --git a/internal/keepass/keepass_test.go b/internal/keepass/keepass_test.go
index 6adb084..dcd8712 100644
--- a/internal/keepass/keepass_test.go
+++ b/internal/keepass/keepass_test.go
@@ -316,6 +316,182 @@ func TestImportSkipsOnDuplicate(t *testing.T) {
}
}
+// TestAddAttachment verifies that Add with a virtual attachment path creates
+// an attachment on the parent entry and it surfaces via WalkIndexes and LoadData.
+func TestAddAttachment(t *testing.T) {
+ dbPath := createTestDB(t)
+ b := newTestBackend(t, dbPath)
+ ctx := context.Background()
+
+ // "Work/Email" already exists; add an attachment to it.
+ attachContent := []byte("attachment binary content")
+ if err := b.Add(ctx, "Work/Email/notes.txt", string(attachContent)); err != nil {
+ // notes.txt is a text extension — IsBinary() would return false,
+ // but isAttachmentPath checks entry existence, not extension.
+ // The parent "Work/Email" exists so this must succeed.
+ t.Fatalf("Add attachment error: %v", err)
+ }
+
+ // Re-open and verify the attachment virtual entry appears.
+ b2 := newTestBackend(t, dbPath)
+ found := false
+ if err := b2.WalkIndexes(ctx, "Work/Email/notes.txt", func(idx *store.Index) error {
+ if idx.Description == "Work/Email/notes.txt" {
+ found = true
+ }
+ return nil
+ }); err != nil {
+ t.Fatalf("WalkIndexes after AddAttachment: %v", err)
+ }
+ if !found {
+ t.Error("Work/Email/notes.txt not found after Add attachment")
+ }
+
+ // LoadData for the attachment virtual entry must return the raw bytes.
+ idx := &store.Index{Description: "Work/Email/notes.txt"}
+ d, err := b2.LoadData(ctx, idx)
+ if err != nil {
+ t.Fatalf("LoadData attachment error: %v", err)
+ }
+ if string(d.Content) != string(attachContent) {
+ t.Errorf("attachment content: got %q, want %q", d.Content, attachContent)
+ }
+}
+
+// TestAddAttachmentReplace verifies that adding an attachment with an existing
+// name replaces the old attachment bytes.
+func TestAddAttachmentReplace(t *testing.T) {
+ dbPath := createTestDB(t)
+ b := newTestBackend(t, dbPath)
+ ctx := context.Background()
+
+ // "Work/Report" already exists and has "report.pdf" attached.
+ // Replace it with new content.
+ newContent := []byte("updated PDF bytes")
+ if err := b.Add(ctx, "Work/Report/report.pdf", string(newContent)); err != nil {
+ t.Fatalf("Add (replace) attachment error: %v", err)
+ }
+
+ // LoadData must return the new bytes.
+ b2 := newTestBackend(t, dbPath)
+ idx := &store.Index{Description: "Work/Report/report.pdf"}
+ d, err := b2.LoadData(ctx, idx)
+ if err != nil {
+ t.Fatalf("LoadData after replace error: %v", err)
+ }
+ if string(d.Content) != string(newContent) {
+ t.Errorf("attachment content after replace: got %q, want %q", d.Content, newContent)
+ }
+}
+
+// TestAddNoParentCreatesTextEntry verifies that Add with a multi-component path
+// whose parent does not exist as an entry creates a new regular text entry rather
+// than treating the last component as an attachment filename. This is the
+// "new nested entry" case where no parent entry has been established yet.
+func TestAddNoParentCreatesTextEntry(t *testing.T) {
+ dbPath := createTestDB(t)
+ b := newTestBackend(t, dbPath)
+ ctx := context.Background()
+
+ // "Work/Ghost" does not exist as an entry, so "Work/Ghost/notes.txt" is
+ // treated as a new text entry (not an attachment).
+ if err := b.Add(ctx, "Work/Ghost/notes.txt", "Password: pw\n"); err != nil {
+ t.Fatalf("Add new text entry error: %v", err)
+ }
+
+ // Re-open: the new entry must appear as a text entry (not an attachment).
+ b2 := newTestBackend(t, dbPath)
+ found := false
+ if err := b2.WalkIndexes(ctx, "Work/Ghost/notes.txt", func(idx *store.Index) error {
+ if idx.Description == "Work/Ghost/notes.txt" {
+ found = true
+ }
+ return nil
+ }); err != nil {
+ t.Fatalf("WalkIndexes: %v", err)
+ }
+ if !found {
+ t.Error("Work/Ghost/notes.txt not found after Add")
+ }
+}
+
+// TestRemoveAttachment verifies that Remove on a virtual attachment path removes
+// only the attachment and leaves the parent entry intact.
+func TestRemoveAttachment(t *testing.T) {
+ dbPath := createTestDB(t)
+ b := newTestBackend(t, dbPath)
+ ctx := context.Background()
+
+ // "Work/Report/report.pdf" is a virtual attachment entry.
+ input := strings.NewReader("y\n")
+ if err := b.Remove(ctx, `^Work/Report/report\.pdf$`, input); err != nil {
+ t.Fatalf("Remove attachment error: %v", err)
+ }
+
+ // Re-open: the parent entry "Work/Report" must still exist.
+ b2 := newTestBackend(t, dbPath)
+ parentFound := false
+ attachFound := false
+ if err := b2.WalkIndexes(ctx, "", func(idx *store.Index) error {
+ switch idx.Description {
+ case "Work/Report":
+ parentFound = true
+ case "Work/Report/report.pdf":
+ attachFound = true
+ }
+ return nil
+ }); err != nil {
+ t.Fatalf("WalkIndexes after Remove attachment: %v", err)
+ }
+ if !parentFound {
+ t.Error("parent entry Work/Report missing after attachment removal")
+ }
+ if attachFound {
+ t.Error("Work/Report/report.pdf still present after Remove attachment")
+ }
+}
+
+// TestAddThenRemoveAttachment verifies the full attachment lifecycle: Add,
+// then Remove via WalkIndexes → LoadData roundtrip.
+func TestAddThenRemoveAttachment(t *testing.T) {
+ dbPath := createTestDB(t)
+ b := newTestBackend(t, dbPath)
+ ctx := context.Background()
+
+ // Add an attachment to an existing entry.
+ if err := b.Add(ctx, "Personal/Note/secret.bin", "binary payload"); err != nil {
+ t.Fatalf("Add attachment error: %v", err)
+ }
+
+ // Verify it's there.
+ b2 := newTestBackend(t, dbPath)
+ idx := &store.Index{Description: "Personal/Note/secret.bin"}