summaryrefslogtreecommitdiff
path: root/internal/keepass/format.go
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 /internal/keepass/format.go
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>
Diffstat (limited to 'internal/keepass/format.go')
-rw-r--r--internal/keepass/format.go96
1 files changed, 96 insertions, 0 deletions
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
+}