diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-17 08:45:13 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-17 08:45:13 +0300 |
| commit | 9e2bf4af8b7b3b4ca2980aa6285482e7db0cd151 (patch) | |
| tree | 96bcd8ad2a64e6dfc9d3bedc9d063eeef1591cf0 /internal/keepass/attachments.go | |
| parent | b53e348d89046ea8de5b82c283fb56980b53cdd8 (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>
Diffstat (limited to 'internal/keepass/attachments.go')
| -rw-r--r-- | internal/keepass/attachments.go | 134 |
1 files changed, 134 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() +} |
