From ea30775eb7bb334cdf2b95e26646ce96db1c0eb3 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 13 Apr 2026 07:02:44 -0700 Subject: [PATCH] Add explicit vault view factories --- TODO.md | 6 +- internal/vaultview/root.go | 26 ++-- internal/vaultview/view.go | 269 +++++++++++++++++++++++++++++++++++++ 3 files changed, 288 insertions(+), 13 deletions(-) create mode 100644 internal/vaultview/view.go diff --git a/TODO.md b/TODO.md index 2519600..c43ae90 100644 --- a/TODO.md +++ b/TODO.md @@ -8,12 +8,10 @@ The product is not complete until the global exit criteria at the end of this fi ## Priority Bugs -- Vault root view bug: - introduce explicit `Vault`, `VaultRoot`, and `VaultRecycleBin` view factories - in `internal/vaultview` and move hidden-root behavior out of UI heuristics. - Vault root view bug: update `internal/appstate` and entries/recycle-bin UI plumbing to use - `VaultRoot` and `VaultRecycleBin` instead of raw datastore paths. + `VaultRoot` and `VaultRecycleBin` instead of raw datastore paths, removing + the hidden-root heuristic from entries browsing. - Vault root view bug: update gRPC/API-facing datastore reads and writes to use logical `VaultRoot` paths while keeping authorization on canonical `Vault` paths. diff --git a/internal/vaultview/root.go b/internal/vaultview/root.go index 7b398ba..bd5a09b 100644 --- a/internal/vaultview/root.go +++ b/internal/vaultview/root.go @@ -5,19 +5,27 @@ import "git.julianfamily.org/keepassgo/internal/vault" // HiddenRoot returns the single synthetic top-level vault group that should be // treated as an internal storage root rather than as a user-visible group. func HiddenRoot(model vault.Model) string { - if len(model.EntriesInPath(nil)) != 0 { + if !hasGroup(model.Groups, []string{KeepassRoot}) { return "" } - groups := model.ChildGroups(nil) - roots := make([]string, 0, len(groups)) + return KeepassRoot +} + +func hasGroup(groups [][]string, path []string) bool { for _, group := range groups { - if group == "Recycle Bin" { + if len(group) != len(path) { continue } - roots = append(roots, group) + match := true + for i := range group { + if group[i] != path[i] { + match = false + break + } + } + if match { + return true + } } - if len(roots) != 1 { - return "" - } - return roots[0] + return false } diff --git a/internal/vaultview/view.go b/internal/vaultview/view.go new file mode 100644 index 0000000..23e82b3 --- /dev/null +++ b/internal/vaultview/view.go @@ -0,0 +1,269 @@ +package vaultview + +import ( + "slices" + + "git.julianfamily.org/keepassgo/internal/vault" +) + +const KeepassRoot = "keepass" + +// View projects the physical vault model into a logical tree for a specific +// product surface. +type View interface { + ChildGroups(path []string) []string + EntriesInPath(path []string) []vault.Entry + EntriesUnderPath(path []string) []vault.Entry + ToPhysicalPath(path []string) []string + FromPhysicalPath(path []string) []string + ToPhysicalEntry(entry vault.Entry) vault.Entry + FromPhysicalEntry(entry vault.Entry) vault.Entry +} + +// Vault returns the physical datastore view. +func Vault(model vault.Model) View { + return physicalView{model: model} +} + +// VaultRoot returns the logical main-vault view rooted at the physical +// keepass storage group. +func VaultRoot(model vault.Model) View { + return rootView{model: model} +} + +// VaultRecycleBin returns the logical recycle-bin view. +func VaultRecycleBin(model vault.Model) View { + return recycleBinView{model: model} +} + +type physicalView struct { + model vault.Model +} + +func (v physicalView) ChildGroups(path []string) []string { + return v.model.ChildGroups(path) +} + +func (v physicalView) EntriesInPath(path []string) []vault.Entry { + return cloneEntries(v.model.EntriesInPath(path)) +} + +func (v physicalView) EntriesUnderPath(path []string) []vault.Entry { + return cloneEntries(v.model.EntriesUnderPath(path)) +} + +func (v physicalView) ToPhysicalPath(path []string) []string { + return clonePath(path) +} + +func (v physicalView) FromPhysicalPath(path []string) []string { + return clonePath(path) +} + +func (v physicalView) ToPhysicalEntry(entry vault.Entry) vault.Entry { + return cloneEntry(entry) +} + +func (v physicalView) FromPhysicalEntry(entry vault.Entry) vault.Entry { + return cloneEntry(entry) +} + +type rootView struct { + model vault.Model +} + +func (v rootView) ChildGroups(path []string) []string { + return v.model.ChildGroups(v.ToPhysicalPath(path)) +} + +func (v rootView) EntriesInPath(path []string) []vault.Entry { + return v.mapEntries(v.model.EntriesInPath(v.ToPhysicalPath(path))) +} + +func (v rootView) EntriesUnderPath(path []string) []vault.Entry { + return v.mapEntries(v.model.EntriesUnderPath(v.ToPhysicalPath(path))) +} + +func (v rootView) ToPhysicalPath(path []string) []string { + if len(path) == 0 { + return []string{KeepassRoot} + } + return append([]string{KeepassRoot}, clonePath(path)...) +} + +func (v rootView) FromPhysicalPath(path []string) []string { + if len(path) == 0 { + return nil + } + if path[0] != KeepassRoot { + return clonePath(path) + } + return clonePath(path[1:]) +} + +func (v rootView) ToPhysicalEntry(entry vault.Entry) vault.Entry { + entry = cloneEntry(entry) + entry.Path = v.ToPhysicalPath(entry.Path) + for i := range entry.History { + entry.History[i].Path = v.ToPhysicalPath(entry.History[i].Path) + } + return entry +} + +func (v rootView) FromPhysicalEntry(entry vault.Entry) vault.Entry { + entry = cloneEntry(entry) + entry.Path = v.FromPhysicalPath(entry.Path) + for i := range entry.History { + entry.History[i].Path = v.FromPhysicalPath(entry.History[i].Path) + } + return entry +} + +func (v rootView) mapEntries(entries []vault.Entry) []vault.Entry { + out := make([]vault.Entry, 0, len(entries)) + for _, entry := range entries { + out = append(out, v.FromPhysicalEntry(entry)) + } + return out +} + +type recycleBinView struct { + model vault.Model +} + +func (v recycleBinView) ChildGroups(path []string) []string { + return childGroups(v.model.RecycleBin, path) +} + +func (v recycleBinView) EntriesInPath(path []string) []vault.Entry { + return entriesInPath(v.model.RecycleBin, path) +} + +func (v recycleBinView) EntriesUnderPath(path []string) []vault.Entry { + var out []vault.Entry + for _, entry := range v.model.RecycleBin { + if len(path) > len(entry.Path) { + continue + } + if !slices.Equal(entry.Path[:len(path)], path) { + continue + } + out = append(out, cloneEntry(entry)) + } + slices.SortFunc(out, func(a, b vault.Entry) int { + switch { + case a.Title < b.Title: + return -1 + case a.Title > b.Title: + return 1 + default: + return 0 + } + }) + return out +} + +func (v recycleBinView) ToPhysicalPath(path []string) []string { + return clonePath(path) +} + +func (v recycleBinView) FromPhysicalPath(path []string) []string { + return clonePath(path) +} + +func (v recycleBinView) ToPhysicalEntry(entry vault.Entry) vault.Entry { + return cloneEntry(entry) +} + +func (v recycleBinView) FromPhysicalEntry(entry vault.Entry) vault.Entry { + return cloneEntry(entry) +} + +func childGroups(entries []vault.Entry, path []string) []string { + seen := map[string]bool{} + var groups []string + for _, entry := range entries { + if len(path) > len(entry.Path) { + continue + } + if !slices.Equal(entry.Path[:len(path)], path) { + continue + } + if len(entry.Path) == len(path) { + continue + } + group := entry.Path[len(path)] + if seen[group] { + continue + } + seen[group] = true + groups = append(groups, group) + } + slices.Sort(groups) + return groups +} + +func entriesInPath(entries []vault.Entry, path []string) []vault.Entry { + var out []vault.Entry + for _, entry := range entries { + if slices.Equal(entry.Path, path) { + out = append(out, cloneEntry(entry)) + } + } + slices.SortFunc(out, func(a, b vault.Entry) int { + switch { + case a.Title < b.Title: + return -1 + case a.Title > b.Title: + return 1 + default: + return 0 + } + }) + return out +} + +func cloneEntries(entries []vault.Entry) []vault.Entry { + if len(entries) == 0 { + return nil + } + out := make([]vault.Entry, len(entries)) + for i := range entries { + out[i] = cloneEntry(entries[i]) + } + return out +} + +func cloneEntry(entry vault.Entry) vault.Entry { + entry.Path = clonePath(entry.Path) + entry.Tags = slices.Clone(entry.Tags) + if entry.Fields != nil { + fields := make(map[string]string, len(entry.Fields)) + for key, value := range entry.Fields { + fields[key] = value + } + entry.Fields = fields + } + if entry.Attachments != nil { + attachments := make(map[string][]byte, len(entry.Attachments)) + for key, value := range entry.Attachments { + attachments[key] = slices.Clone(value) + } + entry.Attachments = attachments + } + if len(entry.History) != 0 { + history := make([]vault.Entry, len(entry.History)) + for i := range entry.History { + history[i] = cloneEntry(entry.History[i]) + } + entry.History = history + } + return entry +} + +func clonePath(path []string) []string { + if len(path) == 0 { + return nil + } + return slices.Clone(path) +}