From 9e2bf4af8b7b3b4ca2980aa6285482e7db0cd151 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 17 Apr 2026 08:45:13 +0300 Subject: 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 --- internal/keepass/attachments.go | 134 ++++++++++++++++++++ internal/keepass/entries.go | 215 ++++++++++++++++++++++++++++++++ internal/keepass/format.go | 96 +++++++++++++++ internal/keepass/fzf.go | 94 ++++++++++++++ internal/keepass/keepass.go | 256 +++++++++++++++++++++++++++++++++++++++ internal/keepass/keepass_test.go | 176 +++++++++++++++++++++++++++ internal/keepass/remove.go | 106 ++++++++++++++++ internal/keepass/search.go | 94 ++++++++++++++ internal/keepass/write.go | 103 ++++++++++++++++ 9 files changed, 1274 insertions(+) create mode 100644 internal/keepass/attachments.go create mode 100644 internal/keepass/entries.go create mode 100644 internal/keepass/format.go create mode 100644 internal/keepass/fzf.go create mode 100644 internal/keepass/keepass.go create mode 100644 internal/keepass/remove.go create mode 100644 internal/keepass/search.go create mode 100644 internal/keepass/write.go (limited to 'internal/keepass') 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: +// User: +// URL: +// Notes: +// +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"} + if _, err := b2.LoadData(ctx, idx); err != nil { + t.Fatalf("LoadData after Add: %v", err) + } + + // Remove it. + input := strings.NewReader("y\n") + if err := b2.Remove(ctx, `^Personal/Note/secret\.bin$`, input); err != nil { + t.Fatalf("Remove attachment error: %v", err) + } + + // Verify it's gone but parent remains. + b3 := newTestBackend(t, dbPath) + var descs []string + if err := b3.WalkIndexes(ctx, "Personal", func(idx *store.Index) error { + descs = append(descs, idx.Description) + return nil + }); err != nil { + t.Fatalf("WalkIndexes: %v", err) + } + for _, d := range descs { + if d == "Personal/Note/secret.bin" { + t.Error("attachment still present after Remove") + } + } +} + // TestFormatParseRoundtrip verifies that formatContent and parseContent are // mutual inverses across a range of inputs. func TestFormatParseRoundtrip(t *testing.T) { diff --git a/internal/keepass/remove.go b/internal/keepass/remove.go new file mode 100644 index 0000000..40bbc9f --- /dev/null +++ b/internal/keepass/remove.go @@ -0,0 +1,106 @@ +package keepass + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "codeberg.org/snonux/foostore/internal/store" +) + +// Remove finds all indexes matching searchTerm and prompts before deleting. +func (b *Backend) Remove(ctx context.Context, searchTerm string, input io.Reader) error { + var indexes store.IndexSlice + if err := b.WalkIndexes(ctx, searchTerm, func(idx *store.Index) error { + indexes = append(indexes, idx) + return nil + }); err != nil { + return err + } + sort.Sort(indexes) + + scanner := bufio.NewScanner(input) + for _, idx := range indexes { + if err := b.confirmAndRemove(idx, scanner); err != nil { + return err + } + } + return nil +} + +// confirmAndRemove prompts the user for deletion confirmation and removes the +// entry (or attachment) on "y". +func (b *Backend) confirmAndRemove(idx *store.Index, scanner *bufio.Scanner) error { + for { + fmt.Print(idx.String()) + fmt.Print("You really want to delete this? (y/n): ") + if !scanner.Scan() { + return nil + } + switch strings.TrimSpace(scanner.Text()) { + case "y": + return b.removeEntry(idx.Description) + case "n": + return nil + } + } +} + +// removeEntry deletes the KeePass entry (or attachment) matching description +// and saves the DB. +// +// If description is a virtual attachment path ("Group/Title/file.bin"), the +// attachment is removed from the parent entry rather than removing the entry +// itself. For regular entries the full entry is deleted from its group. +func (b *Backend) removeEntry(description string) error { + // Detect virtual attachment paths: if the parent exists as a text entry but + // description itself does not, route to attachment removal. + if parentDesc, attachName, ok := b.isAttachmentPath(description); ok { + return b.removeAttachment(parentDesc, attachName) + } + return b.removeTextEntry(description) +} + +// removeTextEntry deletes a regular (non-attachment) entry from its group. +func (b *Backend) removeTextEntry(description string) error { + groupPath, title, err := SplitDescriptionPath(description) + if err != nil { + return fmt.Errorf("keepass remove: %w", err) + } + g := EnsureGroup(b.root(), groupPath) + for i, e := range g.Entries { + if e.GetTitle() == title { + g.Entries = append(g.Entries[:i], g.Entries[i+1:]...) + return b.save() + } + } + return fmt.Errorf("keepass remove: entry %q not found", description) +} + +// ShredAllExported securely deletes every regular file in cfg.ExportDir. +// Delegates to store.ShredFile for the actual destruction. +func (b *Backend) ShredAllExported(ctx context.Context) error { + entries, err := os.ReadDir(b.cfg.ExportDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("listing export dir: %w", err) + } + var lastErr error + for _, e := range entries { + if e.IsDir() { + continue + } + filePath := filepath.Join(b.cfg.ExportDir, e.Name()) + if err := store.ShredFile(ctx, filePath); err != nil { + lastErr = err + } + } + return lastErr +} diff --git a/internal/keepass/search.go b/internal/keepass/search.go new file mode 100644 index 0000000..e345703 --- /dev/null +++ b/internal/keepass/search.go @@ -0,0 +1,94 @@ +package keepass + +import ( + "context" + "fmt" + "path/filepath" + "sort" + + "codeberg.org/snonux/foostore/internal/store" +) + +// Search collects all indexes matching searchTerm, sorts by Description, +// calls onMatch and actionFn per entry, and returns the sorted list. +func (b *Backend) Search( + ctx context.Context, + searchTerm string, + action store.Action, + actionFn func(context.Context, *store.Index, *store.Data) error, + onMatch func(*store.Index), +) ([]*store.Index, error) { + var indexes store.IndexSlice + if err := b.WalkIndexes(ctx, searchTerm, func(idx *store.Index) error { + indexes = append(indexes, idx) + return nil + }); err != nil { + return nil, err + } + sort.Sort(indexes) + + for _, idx := range indexes { + if onMatch != nil { + onMatch(idx) + } + if err := b.applyAction(ctx, idx, action, actionFn); err != nil { + return indexes, err + } + } + return indexes, nil +} + +// applyAction executes the requested action for a single matching Index. +// File-level actions (cat, export) are handled directly; external-tool actions +// (paste, open, edit) are delegated to the caller-supplied actionFn. +func (b *Backend) applyAction(ctx context.Context, idx *store.Index, action store.Action, actionFn func(context.Context, *store.Index, *store.Data) error) error { + switch action { + case store.ActionNone: + return nil + case store.ActionCat: + return b.actionCat(ctx, idx) + case store.ActionExport: + return b.actionExport(ctx, idx, false) + case store.ActionPathExport: + return b.actionExport(ctx, idx, true) + default: + if actionFn != nil { + d, err := b.LoadData(ctx, idx) + if err != nil { + return err + } + return actionFn(ctx, idx, d) + } + } + return nil +} + +// actionCat prints the decrypted content of an index entry to stdout. +// Binary entries are skipped with a warning. +func (b *Backend) actionCat(ctx context.Context, idx *store.Index) error { + if idx.IsBinary() { + fmt.Println("Not displaying/pasting binary data!") + return nil + } + d, err := b.LoadData(ctx, idx) + if err != nil { + return err + } + fmt.Print(d.String()) + return nil +} + +// actionExport writes the decrypted content to cfg.ExportDir. +// When fullPath is true the full description path is used; when false only the +// basename is used (matching the :export vs :pathexport behaviour). +func (b *Backend) actionExport(ctx context.Context, idx *store.Index, fullPath bool) error { + d, err := b.LoadData(ctx, idx) + if err != nil { + return err + } + destFile := idx.Description + if !fullPath { + destFile = filepath.Base(idx.Description) + } + return d.Export(ctx, b.cfg.ExportDir, destFile) +} diff --git a/internal/keepass/write.go b/internal/keepass/write.go new file mode 100644 index 0000000..f79a38d --- /dev/null +++ b/internal/keepass/write.go @@ -0,0 +1,103 @@ +package keepass + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Add creates or updates a KeePass entry from plaintext data. +// +// Attachment detection: if the last path component does not match any existing +// standalone entry but its parent does, the call is routed to addAttachment so +// that "Group/Title/file.bin" attaches raw bytes to "Group/Title". The parent +// entry must already exist; return an error if it does not. +// Otherwise, the description is treated as a normal text entry and its fields +// are parsed from data via parseContent. +func (b *Backend) Add(ctx context.Context, description, data string) error { + if parentDesc, attachName, ok := b.isAttachmentPath(description); ok { + return b.addAttachment(parentDesc, attachName, []byte(data)) + } + return b.addTextEntry(description, data) +} + +// addTextEntry creates or updates the KeePass entry at description using the +// fields parsed from data. This is the normal (non-attachment) Add path. +func (b *Backend) addTextEntry(description, data string) error { + groupPath, title, err := SplitDescriptionPath(description) + if err != nil { + return fmt.Errorf("keepass add: %w", err) + } + password, user, url, notes := parseContent([]byte(data)) + 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() +} + +// Import reads a file from srcPath and stores it under destPath. +// When force is false and an entry already exists at destPath, the import is +// skipped silently (a warning is printed to stderr) and nil is returned — +// matching the interface contract and the behaviour of store.Store.Import. +func (b *Backend) Import(ctx context.Context, srcPath, destPath string, force bool) error { + if !force && b.entryExists(destPath) { + // Skip without error; warn on stderr so the operator knows something + // was skipped, consistent with store.Store.Import(force=false). + fmt.Fprintf(os.Stderr, "Warning: keepass entry %q already exists, skipping (use force to overwrite)\n", destPath) + return nil + } + content, err := os.ReadFile(srcPath) + if err != nil { + return fmt.Errorf("keepass import: reading %q: %w", srcPath, err) + } + return b.Add(ctx, destPath, string(content)) +} + +// entryExists reports whether an entry with the given description already +// exists in the KeePass database. Used by Import to detect duplicates. +func (b *Backend) entryExists(description string) bool { + for _, ve := range walkEntries(b.root()) { + if ve.description == description { + return true + } + } + return false +} + +// ImportRecursive walks directory recursively and imports every regular file +// under destDir, preserving the relative sub-directory structure in the +// description path. Uses filepath.WalkDir to descend into sub-directories. +func (b *Backend) ImportRecursive(ctx context.Context, directory, destDir string) error { + baseDir := strings.TrimRight(destDir, "/") + return walkDirFilesRecursive(directory, func(relFile string) error { + destPath := filepath.Join(baseDir, relFile) + srcPath := filepath.Join(directory, relFile) + return b.Import(ctx, srcPath, destPath, false) + }) +} + +// walkDirFilesRecursive walks a directory tree calling fn(relativeFilePath) +// for each regular file found at any depth. Uses filepath.WalkDir so that +// sub-directories are descended into. +func walkDirFilesRecursive(dir string, fn func(string) error) error { + return filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return fmt.Errorf("keepass: walking %q: %w", path, err) + } + if d.IsDir() { + return nil + } + rel, relErr := filepath.Rel(dir, path) + if relErr != nil { + return fmt.Errorf("keepass: computing relative path for %q: %w", path, relErr) + } + // Use forward slashes in the description regardless of OS separator. + return fn(filepath.ToSlash(rel)) + }) +} -- cgit v1.2.3