From afe9680d7a9fe8de2167f81f7c4bcf693d217b8e Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:21:02 -0700 Subject: [PATCH 01/13] Complete search section behavior --- appstate/state.go | 13 ++++ appstate/state_test.go | 136 +++++++++++++++++++++++++++++++++++++++++ main.go | 10 ++- main_test.go | 82 +++++++++++++++++++++++++ 4 files changed, 240 insertions(+), 1 deletion(-) diff --git a/appstate/state.go b/appstate/state.go index cd15381..e963534 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -161,6 +161,19 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry { } } +func (s State) SearchPathContext(entry vault.Entry) string { + path := slices.Clone(entry.Path) + switch s.Section { + case SectionTemplates: + if len(path) == 0 || path[0] != "Templates" { + path = append([]string{"Templates"}, path...) + } + case SectionRecycleBin: + path = append([]string{"Recycle Bin"}, path...) + } + return strings.Join(path, " / ") +} + func entriesInPath(entries []vault.Entry, path []string) []vault.Entry { var out []vault.Entry for _, entry := range entries { diff --git a/appstate/state_test.go b/appstate/state_test.go index a234619..d8287e4 100644 --- a/appstate/state_test.go +++ b/appstate/state_test.go @@ -117,6 +117,142 @@ func TestVisibleEntriesUsesRecycleBinSection(t *testing.T) { } } +func TestVisibleEntriesUsesGlobalSearchWithinTemplateSection(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + Templates: []vault.Entry{ + {ID: "tpl-1", Title: "Website Login", URL: "https://accounts.example.com", Path: []string{"Templates", "Web"}}, + {ID: "tpl-2", Title: "SSH Login", URL: "ssh://infra.internal", Path: []string{"Templates", "Infra"}}, + }, + }, + }, + Section: SectionTemplates, + CurrentPath: []string{"Templates", "Web"}, + SearchQuery: "infra", + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() error = %v", err) + } + + if len(got) != 1 || got[0].ID != "tpl-2" { + t.Fatalf("VisibleEntries() = %#v, want global template search result tpl-2", got) + } +} + +func TestVisibleEntriesResetToCurrentTemplatePathAfterClearingSearch(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + Templates: []vault.Entry{ + {ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}}, + {ID: "tpl-2", Title: "Email Login", Path: []string{"Templates", "Web"}}, + {ID: "tpl-3", Title: "SSH Login", Path: []string{"Templates", "Infra"}}, + }, + }, + }, + Section: SectionTemplates, + CurrentPath: []string{"Templates", "Web"}, + SearchQuery: "ssh", + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() with search error = %v", err) + } + if len(got) != 1 || got[0].ID != "tpl-3" { + t.Fatalf("VisibleEntries() with search = %#v, want tpl-3", got) + } + + state.SearchQuery = "" + got, err = state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() after clearing search error = %v", err) + } + + if len(got) != 2 { + t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 2", len(got)) + } + if titles := []string{got[0].Title, got[1].Title}; !slices.Equal(titles, []string{"Email Login", "Website Login"}) { + t.Fatalf("VisibleEntries() after clearing search titles = %v, want [Email Login Website Login]", titles) + } +} + +func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + RecycleBin: []vault.Entry{ + {ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}}, + {ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}}, + }, + }, + }, + Section: SectionRecycleBin, + CurrentPath: []string{"Root", "Internet"}, + SearchQuery: "climate", + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() error = %v", err) + } + + if len(got) != 1 || got[0].ID != "deleted-2" { + t.Fatalf("VisibleEntries() = %#v, want global recycle-bin search result deleted-2", got) + } +} + +func TestSearchPathContextIncludesSectionRoots(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + section Section + entry vault.Entry + want string + }{ + { + name: "entries use direct path", + section: SectionEntries, + entry: vault.Entry{Path: []string{"Root", "Internet"}}, + want: "Root / Internet", + }, + { + name: "templates retain templates root", + section: SectionTemplates, + entry: vault.Entry{Path: []string{"Templates", "Web"}}, + want: "Templates / Web", + }, + { + name: "recycle bin prefixes root label", + section: SectionRecycleBin, + entry: vault.Entry{Path: []string{"Root", "Internet"}}, + want: "Recycle Bin / Root / Internet", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + state := State{Section: tt.section} + if got := state.SearchPathContext(tt.entry); got != tt.want { + t.Fatalf("SearchPathContext(%v) = %q, want %q", tt.entry.Path, got, tt.want) + } + }) + } +} + func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) { t.Parallel() diff --git a/main.go b/main.go index 056a420..a4abfe6 100644 --- a/main.go +++ b/main.go @@ -269,6 +269,14 @@ func (u *ui) filteredTitles() []string { return titles } +func (u *ui) visiblePathContexts() []string { + paths := make([]string, 0, len(u.visible)) + for _, item := range u.visible { + paths = append(paths, u.state.SearchPathContext(item)) + } + return paths +} + func (u *ui) selectedEntry() (entry, bool) { for _, item := range u.visible { if item.ID == u.state.SelectedEntryID { @@ -862,7 +870,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item if strings.TrimSpace(u.search.Text()) == "" { return layout.Dimensions{} } - lbl := material.Label(u.theme, unit.Sp(11), strings.Join(item.Path, " / ")) + lbl := material.Label(u.theme, unit.Sp(11), u.state.SearchPathContext(item)) lbl.Color = mutedColor return lbl.Layout(gtx) }), diff --git a/main_test.go b/main_test.go index 55e2c71..99bdbce 100644 --- a/main_test.go +++ b/main_test.go @@ -43,6 +43,88 @@ func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) { } } +func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T) { + t.Parallel() + + modes := []string{"desktop", "phone"} + for _, mode := range modes { + mode := mode + t.Run(mode, func(t *testing.T) { + t.Parallel() + + u := newUIWithModel(mode, vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}}, + {ID: "entry-2", Title: "HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}}, + }, + Templates: []vault.Entry{ + {ID: "tpl-1", Title: "Website Login", URL: "https://accounts.example.com", Path: []string{"Templates", "Web"}}, + {ID: "tpl-2", Title: "SSH Login", URL: "ssh://infra.internal", Path: []string{"Templates", "Infra"}}, + }, + RecycleBin: []vault.Entry{ + {ID: "deleted-1", Title: "Deleted Bellagio", URL: "https://bellagio.example.com", Path: []string{"Root", "Internet"}}, + {ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}}, + }, + }) + + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.search.SetText("climate") + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"HVAC"}) { + t.Fatalf("entries filteredTitles() = %v, want [HVAC]", got) + } + + u.showTemplatesSection() + u.currentPath = []string{"Templates", "Web"} + u.search.SetText("infra") + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"SSH Login"}) { + t.Fatalf("templates filteredTitles() = %v, want [SSH Login]", got) + } + if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Templates / Infra"}) { + t.Fatalf("templates visiblePathContexts() = %v, want [Templates / Infra]", got) + } + + u.showRecycleBinSection() + u.search.SetText("climate") + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Deleted HVAC"}) { + t.Fatalf("recycle filteredTitles() = %v, want [Deleted HVAC]", got) + } + if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Recycle Bin / Root / Home"}) { + t.Fatalf("recycle visiblePathContexts() = %v, want [Recycle Bin / Root / Home]", got) + } + }) + } +} + +func TestUIClearingSearchResetsToCurrentSectionListing(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Templates: []vault.Entry{ + {ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}}, + {ID: "tpl-2", Title: "Email Login", Path: []string{"Templates", "Web"}}, + {ID: "tpl-3", Title: "SSH Login", Path: []string{"Templates", "Infra"}}, + }, + }) + + u.showTemplatesSection() + u.currentPath = []string{"Templates", "Web"} + u.search.SetText("ssh") + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"SSH Login"}) { + t.Fatalf("filteredTitles() with search = %v, want [SSH Login]", got) + } + + u.search.SetText("") + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Email Login", "Website Login"}) { + t.Fatalf("filteredTitles() after clearing search = %v, want [Email Login Website Login]", got) + } +} + func TestUIChildGroupsComeFromVaultModel(t *testing.T) { t.Parallel() From afa6c8821f56e6116f3168ea271f2958871fd8d2 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:28:17 -0700 Subject: [PATCH 02/13] Add keyboard-first accessibility behavior --- main.go | 43 +++--- main_test.go | 175 +++++++++++++++++++++ ui_accessibility.go | 112 ++++++++++++++ ui_forms.go | 34 +++-- ui_keyboard.go | 361 ++++++++++++++++++++++++++++++++++++++++++++ ui_shortcuts.go | 39 ++--- 6 files changed, 712 insertions(+), 52 deletions(-) create mode 100644 ui_accessibility.go create mode 100644 ui_keyboard.go diff --git a/main.go b/main.go index ec00a42..056a420 100644 --- a/main.go +++ b/main.go @@ -138,6 +138,7 @@ type ui struct { loadingMessage string statusMessage string errorMessage string + keyboardFocus focusID } var ( @@ -213,6 +214,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession) *ui { u.eyeOffIcon, _ = widget.NewIcon(icons.ActionVisibilityOff) u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy) u.passwordProfile.SetText("strong") + u.keyboardFocus = focusSearch u.filter() return u } @@ -772,7 +774,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { gtx.Constraints.Min.X = gtx.Constraints.Max.X } - return outlinedField(gtx, func(gtx layout.Context) layout.Dimensions { + return outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions { editor := material.Editor(u.theme, &u.search, "Search vault") editor.Color = u.theme.Palette.Fg editor.HintColor = mutedColor @@ -876,7 +878,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item ) }) } - if item.ID == u.state.SelectedEntryID { + if item.ID == u.state.SelectedEntryID || u.isFocused(listFocusID(idx)) { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { size := gtx.Constraints.Min @@ -886,8 +888,14 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item if size.Y == 0 { size.Y = gtx.Constraints.Max.Y } - paint.FillShape(gtx.Ops, selectedColor, clip.Rect{Max: size}.Op()) - paint.FillShape(gtx.Ops, selectedEdge, clip.Rect{Max: image.Pt(4, size.Y)}.Op()) + fillColor := selectedColor + edgeColor := selectedEdge + if u.isFocused(listFocusID(idx)) && item.ID != u.state.SelectedEntryID { + fillColor = color.NRGBA{R: 235, G: 241, B: 238, A: 255} + edgeColor = accentColor + } + paint.FillShape(gtx.Ops, fillColor, clip.Rect{Max: size}.Op()) + paint.FillShape(gtx.Ops, edgeColor, clip.Rect{Max: image.Pt(4, size.Y)}.Op()) return layout.Dimensions{Size: size} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { @@ -1104,8 +1112,7 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { u.filter() } btn := material.Button(u.theme, &u.breadcrumbs[index], label) - btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255} - btn.Color = accentColor + btn.Background, btn.Color = buttonFocusColors(u.isFocused(breadcrumbFocusID(index))) btn.TextSize = unit.Sp(12) btn.Inset = layout.Inset{Top: 6, Bottom: 6, Left: 10, Right: 10} return btn.Layout(gtx) @@ -1213,14 +1220,14 @@ func compactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { }) } -func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions { - border := color.NRGBA{R: 202, G: 194, B: 180, A: 255} +func outlinedFieldState(gtx layout.Context, focused bool, w layout.Widget) layout.Dimensions { + appearance := fieldFocusAppearance(gtx.Metric, focused) size := gtx.Constraints.Min if size.X == 0 { size.X = gtx.Constraints.Max.X } if size.Y == 0 { - size.Y = gtx.Dp(unit.Dp(44)) + size.Y = appearance.MinHeight } gtx.Constraints.Min = size return layout.Stack{}.Layout(gtx, @@ -1229,10 +1236,13 @@ func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions { return layout.Dimensions{Size: size} }), layout.Expanded(func(gtx layout.Context) layout.Dimensions { - paint.FillShape(gtx.Ops, border, clip.Rect{Max: image.Pt(size.X, 1)}.Op()) - paint.FillShape(gtx.Ops, border, clip.Rect{Min: image.Pt(0, size.Y-1), Max: image.Pt(size.X, size.Y)}.Op()) - paint.FillShape(gtx.Ops, border, clip.Rect{Max: image.Pt(1, size.Y)}.Op()) - paint.FillShape(gtx.Ops, border, clip.Rect{Min: image.Pt(size.X-1, 0), Max: image.Pt(size.X, size.Y)}.Op()) + return drawFocusOutline(gtx, appearance, size) + }), + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(size.X, 1)}.Op()) + paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(0, size.Y-1), Max: image.Pt(size.X, size.Y)}.Op()) + paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(1, size.Y)}.Op()) + paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(size.X-1, 0), Max: image.Pt(size.X, size.Y)}.Op()) return layout.Dimensions{Size: size} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { @@ -1245,8 +1255,8 @@ func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions { if dims.Size.Y < min.Y { dims.Size.Y = min.Y } - if dims.Size.Y < gtx.Dp(unit.Dp(44)) { - dims.Size.Y = gtx.Dp(unit.Dp(44)) + if dims.Size.Y < appearance.MinHeight { + dims.Size.Y = appearance.MinHeight } return dims }), @@ -1255,8 +1265,7 @@ func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions { func tonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string) layout.Dimensions { btn := material.Button(th, click, label) - btn.Background = color.NRGBA{R: 231, G: 239, B: 235, A: 255} - btn.Color = accentColor + btn.Background, btn.Color = buttonFocusColors(false) btn.CornerRadius = unit.Dp(10) btn.TextSize = unit.Sp(15) return btn.Layout(gtx) diff --git a/main_test.go b/main_test.go index d2a5b55..55e2c71 100644 --- a/main_test.go +++ b/main_test.go @@ -10,6 +10,9 @@ import ( "slices" "testing" + "gioui.org/io/key" + "gioui.org/unit" + "git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/session" "git.julianfamily.org/keepassgo/vault" @@ -757,6 +760,178 @@ func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) { } } +func TestUIKeyboardNavigationMovesAcrossBreadcrumbsListAndDetail(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "bellagio", + Title: "Bellagio", + Username: "rustyryan", + Path: []string{"Root", "Internet"}, + }, + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + + if got := u.keyboardFocus; got != focusSearch { + t.Fatalf("keyboardFocus = %q, want %q", got, focusSearch) + } + + u.handleKeyPress(key.NameTab, 0) + if got := u.keyboardFocus; got != breadcrumbFocusID(0) { + t.Fatalf("keyboardFocus after Tab = %q, want %q", got, breadcrumbFocusID(0)) + } + + u.handleKeyPress(key.NameTab, 0) + if got := u.keyboardFocus; got != listFocusID(0) { + t.Fatalf("keyboardFocus after second Tab = %q, want %q", got, listFocusID(0)) + } + if got := u.state.SelectedEntryID; got != "bellagio" { + t.Fatalf("SelectedEntryID after list focus = %q, want %q", got, "bellagio") + } + + u.handleKeyPress(key.NameDownArrow, 0) + if got := u.keyboardFocus; got != listFocusID(1) { + t.Fatalf("keyboardFocus after Down = %q, want %q", got, listFocusID(1)) + } + if got := u.state.SelectedEntryID; got != "vault-console" { + t.Fatalf("SelectedEntryID after Down = %q, want %q", got, "vault-console") + } + + u.handleKeyPress(key.NameTab, 0) + if got := u.keyboardFocus; got != detailFocusID(detailFieldTitle) { + t.Fatalf("keyboardFocus after detail Tab = %q, want %q", got, detailFocusID(detailFieldTitle)) + } +} + +func TestUIKeyboardNavigationActivatesBreadcrumbs(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + u.keyboardFocus = breadcrumbFocusID(0) + + u.handleKeyPress(key.NameRightArrow, 0) + if got := u.keyboardFocus; got != breadcrumbFocusID(1) { + t.Fatalf("keyboardFocus after Right = %q, want %q", got, breadcrumbFocusID(1)) + } + + u.handleKeyPress(key.NameReturn, 0) + if got := u.currentPath; !slices.Equal(got, []string{"Root"}) { + t.Fatalf("currentPath after breadcrumb activation = %v, want [Root]", got) + } +} + +func TestUIKeyboardShortcutsMoveFocusForSearchAndNewEntry(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + u.state.SelectedEntryID = "vault-console" + u.loadSelectedEntryIntoEditor() + u.keyboardFocus = listFocusID(0) + + u.handleKeyPress("F", key.ModShortcut) + if got := u.keyboardFocus; got != focusSearch { + t.Fatalf("keyboardFocus after shortcut search = %q, want %q", got, focusSearch) + } + + u.handleKeyPress("N", key.ModShortcut) + if got := u.state.SelectedEntryID; got != "" { + t.Fatalf("SelectedEntryID after shortcut new-entry = %q, want empty", got) + } + if got := u.keyboardFocus; got != detailFocusID(detailFieldTitle) { + t.Fatalf("keyboardFocus after shortcut new-entry = %q, want %q", got, detailFocusID(detailFieldTitle)) + } +} + +func TestUIAccessibilityLabelsDescribeFocusableControls(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + + if got := u.accessibilityLabel(focusSearch); got != "Search vault" { + t.Fatalf("accessibilityLabel(search) = %q, want %q", got, "Search vault") + } + if got := u.accessibilityLabel(breadcrumbFocusID(1)); got != "Navigate to Root" { + t.Fatalf("accessibilityLabel(breadcrumb) = %q, want %q", got, "Navigate to Root") + } + if got := u.accessibilityLabel(listFocusID(0)); got != "Select entry Vault Console" { + t.Fatalf("accessibilityLabel(list) = %q, want %q", got, "Select entry Vault Console") + } + if got := u.accessibilityLabel(detailFocusID(detailFieldPassword)); got != "Edit Password" { + t.Fatalf("accessibilityLabel(detail password) = %q, want %q", got, "Edit Password") + } +} + +func TestFieldFocusAppearanceScalesForHighDPI(t *testing.T) { + t.Parallel() + + lo := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, true) + hi := fieldFocusAppearance(unit.Metric{PxPerDp: 2.5, PxPerSp: 2.5}, true) + unfocused := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, false) + + if got := lo.MinHeight; got != 44 { + t.Fatalf("fieldFocusAppearance(low).MinHeight = %d, want 44", got) + } + if got := hi.MinHeight; got != 110 { + t.Fatalf("fieldFocusAppearance(high).MinHeight = %d, want 110", got) + } + if got := lo.OutlineWidth; got < 2 { + t.Fatalf("fieldFocusAppearance(low).OutlineWidth = %d, want >= 2", got) + } + if hi.OutlineWidth <= lo.OutlineWidth { + t.Fatalf("fieldFocusAppearance(high).OutlineWidth = %d, want > %d", hi.OutlineWidth, lo.OutlineWidth) + } + if lo.OutlineColor == unfocused.OutlineColor { + t.Fatalf("fieldFocusAppearance().OutlineColor focused = %#v, want distinct from unfocused %#v", lo.OutlineColor, unfocused.OutlineColor) + } +} + func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) { t.Parallel() diff --git a/ui_accessibility.go b/ui_accessibility.go new file mode 100644 index 0000000..5ee2289 --- /dev/null +++ b/ui_accessibility.go @@ -0,0 +1,112 @@ +package main + +import ( + "fmt" + "image" + "image/color" + "strings" + + "gioui.org/layout" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" +) + +type focusAppearance struct { + BorderColor color.NRGBA + OutlineColor color.NRGBA + OutlineWidth int + MinHeight int +} + +func fieldFocusAppearance(metric unit.Metric, focused bool) focusAppearance { + appearance := focusAppearance{ + BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255}, + OutlineColor: color.NRGBA{A: 0}, + OutlineWidth: max(1, metric.Dp(unit.Dp(1))), + MinHeight: metric.Dp(unit.Dp(44)), + } + if focused { + appearance.BorderColor = accentColor + appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72} + appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2))) + } + return appearance +} + +func buttonFocusColors(focused bool) (background color.NRGBA, text color.NRGBA) { + background = color.NRGBA{R: 231, G: 239, B: 235, A: 255} + text = accentColor + if focused { + background = color.NRGBA{R: 214, G: 229, B: 221, A: 255} + } + return background, text +} + +func (u *ui) accessibilityLabel(id focusID) string { + switch { + case id == focusSearch: + return "Search vault" + case strings.HasPrefix(string(id), "breadcrumb:"): + index := focusIndex(id) + crumbs := u.breadcrumbLabels() + if index >= 0 && index < len(crumbs) { + return fmt.Sprintf("Navigate to %s", crumbs[index]) + } + case strings.HasPrefix(string(id), "list:"): + index := focusIndex(id) + if index >= 0 && index < len(u.visible) { + return fmt.Sprintf("Select entry %s", u.visible[index].Title) + } + case strings.HasPrefix(string(id), "detail:"): + name := strings.TrimPrefix(string(id), "detail:") + return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name))) + } + return "" +} + +func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions { + if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 { + return layout.Dimensions{Size: size} + } + + width := appearance.OutlineWidth + paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op()) + paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op()) + paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op()) + paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op()) + return layout.Dimensions{Size: size} +} + +func (u *ui) isFocused(id focusID) bool { + return u.keyboardFocus == id +} + +func detailFieldLabel(field detailField) string { + switch field { + case detailFieldID: + return "ID" + case detailFieldTitle: + return "Title" + case detailFieldUsername: + return "Username" + case detailFieldPassword: + return "Password" + case detailFieldURL: + return "URL" + case detailFieldPath: + return "Path" + case detailFieldTags: + return "Tags" + case detailFieldPasswordProfile: + return "Password Profile" + case detailFieldNotes: + return "Notes" + case detailFieldFields: + return "Custom Fields" + case detailFieldHistoryIndex: + return "History Index" + default: + return strings.ReplaceAll(string(field), "-", " ") + } +} diff --git a/ui_forms.go b/ui_forms.go index d0222f9..3ca93e4 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -111,27 +111,27 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions { func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(labeledEditor(u.theme, "ID", &u.entryID, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "ID", &u.entryID, false, u.isFocused(detailFocusID(detailFieldID)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Title", &u.entryTitle, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Username", &u.entryUsername, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Password", &u.entryPassword, true)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Password", &u.entryPassword, true, u.isFocused(detailFocusID(detailFieldPassword)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "URL", &u.entryURL, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "URL", &u.entryURL, false, u.isFocused(detailFocusID(detailFieldURL)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Path", &u.entryPath, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Path", &u.entryPath, false, u.isFocused(detailFocusID(detailFieldPath)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Tags", &u.entryTags, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Password Profile", &u.passwordProfile, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Notes", &u.entryNotes, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Custom Fields (key=value)", &u.entryFields, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Custom Fields (key=value)", &u.entryFields, false, u.isFocused(detailFocusID(detailFieldFields)))), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(labeledEditor(u.theme, "History Index", &u.historyIndex, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { switch u.state.Section { @@ -211,6 +211,16 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { } func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sensitive bool) layout.Widget { + return labeledEditorWithFocus(th, label, editor, sensitive, false) +} + +func labeledEditorWithFocus( + th *material.Theme, + label string, + editor *widget.Editor, + sensitive bool, + focused bool, +) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -219,7 +229,7 @@ func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sens return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return outlinedField(gtx, func(gtx layout.Context) layout.Dimensions { + return outlinedFieldState(gtx, focused, func(gtx layout.Context) layout.Dimensions { mask := editor.Mask if sensitive { editor.Mask = '•' diff --git a/ui_keyboard.go b/ui_keyboard.go new file mode 100644 index 0000000..10ee114 --- /dev/null +++ b/ui_keyboard.go @@ -0,0 +1,361 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + + "gioui.org/io/key" + "git.julianfamily.org/keepassgo/appstate" +) + +type focusID string + +type detailField string + +const ( + focusSearch focusID = "search" + + detailFieldID detailField = "id" + detailFieldTitle detailField = "title" + detailFieldUsername detailField = "username" + detailFieldPassword detailField = "password" + detailFieldURL detailField = "url" + detailFieldPath detailField = "path" + detailFieldTags detailField = "tags" + detailFieldPasswordProfile detailField = "password-profile" + detailFieldNotes detailField = "notes" + detailFieldFields detailField = "fields" + detailFieldHistoryIndex detailField = "history-index" +) + +func breadcrumbFocusID(index int) focusID { + return focusID(fmt.Sprintf("breadcrumb:%d", index)) +} + +func listFocusID(index int) focusID { + return focusID(fmt.Sprintf("list:%d", index)) +} + +func detailFocusID(field detailField) focusID { + return focusID("detail:" + string(field)) +} + +func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool { + if u.handleShortcutKey(name, modifiers) { + return true + } + + switch name { + case key.NameTab: + delta := 1 + if modifiers.Contain(key.ModShift) { + delta = -1 + } + u.moveKeyboardFocus(delta) + return true + case key.NameLeftArrow, key.NameRightArrow, key.NameUpArrow, key.NameDownArrow, key.NameReturn: + return u.handleFocusedKey(name) + default: + return false + } +} + +func (u *ui) moveKeyboardFocus(delta int) { + order := u.focusOrder() + if len(order) == 0 { + return + } + + current := canonicalFocusID(u.keyboardFocus) + index := 0 + for i, item := range order { + if canonicalFocusID(item) == current { + index = i + break + } + } + + index += delta + if index < 0 { + index = len(order) - 1 + } + if index >= len(order) { + index = 0 + } + u.setKeyboardFocus(order[index]) +} + +func (u *ui) focusOrder() []focusID { + order := []focusID{focusSearch} + if u.state.Section != appstate.SectionRecycleBin { + order = append(order, breadcrumbFocusID(0)) + } + if len(u.visible) > 0 { + order = append(order, listFocusID(u.focusedListIndexOrZero())) + } + order = append(order, detailFocusID(u.focusedDetailFieldOrDefault())) + return order +} + +func (u *ui) setKeyboardFocus(id focusID) { + u.keyboardFocus = id + if strings.HasPrefix(string(id), "list:") { + u.focusListIndex(focusIndex(id)) + } +} + +func (u *ui) handleFocusedKey(name key.Name) bool { + switch { + case u.keyboardFocus == focusSearch: + if name == key.NameDownArrow && len(u.visible) > 0 { + u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero())) + return true + } + case strings.HasPrefix(string(u.keyboardFocus), "breadcrumb:"): + return u.handleBreadcrumbKey(name) + case strings.HasPrefix(string(u.keyboardFocus), "list:"): + return u.handleListKey(name) + case strings.HasPrefix(string(u.keyboardFocus), "detail:"): + return u.handleDetailKey(name) + } + return false +} + +func (u *ui) handleBreadcrumbKey(name key.Name) bool { + crumbs := u.breadcrumbLabels() + if len(crumbs) == 0 { + return false + } + + index := focusIndex(u.keyboardFocus) + switch name { + case key.NameLeftArrow: + if index > 0 { + u.keyboardFocus = breadcrumbFocusID(index - 1) + } + return true + case key.NameRightArrow: + if index < len(crumbs)-1 { + u.keyboardFocus = breadcrumbFocusID(index + 1) + } + return true + case key.NameDownArrow: + if len(u.visible) > 0 { + u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero())) + } + return true + case key.NameReturn: + u.activateBreadcrumb(index) + return true + default: + return false + } +} + +func (u *ui) handleListKey(name key.Name) bool { + if len(u.visible) == 0 { + return false + } + + index := focusIndex(u.keyboardFocus) + switch name { + case key.NameUpArrow: + if index > 0 { + u.setKeyboardFocus(listFocusID(index - 1)) + } + return true + case key.NameDownArrow: + if index < len(u.visible)-1 { + u.setKeyboardFocus(listFocusID(index + 1)) + } + return true + case key.NameLeftArrow: + u.keyboardFocus = breadcrumbFocusID(len(u.breadcrumbLabels()) - 1) + return true + case key.NameRightArrow, key.NameReturn: + u.keyboardFocus = detailFocusID(u.focusedDetailFieldOrDefault()) + return true + default: + return false + } +} + +func (u *ui) handleDetailKey(name key.Name) bool { + fields := detailFocusOrder() + index := u.focusedDetailIndex() + + switch name { + case key.NameUpArrow: + if index > 0 { + u.keyboardFocus = detailFocusID(fields[index-1]) + } + return true + case key.NameDownArrow: + if index < len(fields)-1 { + u.keyboardFocus = detailFocusID(fields[index+1]) + } + return true + case key.NameLeftArrow: + if len(u.visible) > 0 { + u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero())) + } + return true + default: + return false + } +} + +func (u *ui) handleShortcutKey(name key.Name, modifiers key.Modifiers) bool { + if !modifiers.Contain(key.ModShortcut) { + return false + } + + switch name { + case "F": + _ = u.performShortcut(shortcutSearch) + case "S": + _ = u.performShortcut(shortcutSave) + case "L": + _ = u.performShortcut(shortcutLock) + case "N": + _ = u.performShortcut(shortcutNewEntry) + case "U": + _ = u.performShortcut(shortcutCopyUser) + case "P": + _ = u.performShortcut(shortcutCopyPassword) + case "O": + _ = u.performShortcut(shortcutCopyURL) + default: + return false + } + + return true +} + +func (u *ui) activateBreadcrumb(index int) { + if index <= 0 { + u.currentPath = nil + } else { + crumbs := u.breadcrumbLabels() + u.currentPath = append([]string{}, crumbs[1:index+1]...) + } + u.filter() + if index >= len(u.breadcrumbLabels()) { + index = len(u.breadcrumbLabels()) - 1 + } + if index < 0 { + index = 0 + } + u.keyboardFocus = breadcrumbFocusID(index) +} + +func (u *ui) breadcrumbLabels() []string { + if u.state.Section == appstate.SectionRecycleBin { + return nil + } + + labels := append([]string{"Vault"}, u.currentPath...) + if u.state.Section == appstate.SectionTemplates { + labels = append([]string{"Templates"}, u.currentPath...) + } + return labels +} + +func (u *ui) focusListIndex(index int) { + if len(u.visible) == 0 { + return + } + if index < 0 { + index = 0 + } + if index >= len(u.visible) { + index = len(u.visible) - 1 + } + + u.keyboardFocus = listFocusID(index) + u.state.SelectedEntryID = u.visible[index].ID + u.loadSelectedEntryIntoEditor() +} + +func (u *ui) focusedListIndexOrZero() int { + if strings.HasPrefix(string(u.keyboardFocus), "list:") { + index := focusIndex(u.keyboardFocus) + if index >= 0 && index < len(u.visible) { + return index + } + } + + for i, item := range u.visible { + if item.ID == u.state.SelectedEntryID { + return i + } + } + + return 0 +} + +func (u *ui) focusedDetailFieldOrDefault() detailField { + if strings.HasPrefix(string(u.keyboardFocus), "detail:") { + name := strings.TrimPrefix(string(u.keyboardFocus), "detail:") + for _, field := range detailFocusOrder() { + if string(field) == name { + return field + } + } + } + + return detailFieldTitle +} + +func (u *ui) focusedDetailIndex() int { + current := u.focusedDetailFieldOrDefault() + for i, field := range detailFocusOrder() { + if field == current { + return i + } + } + return 0 +} + +func detailFocusOrder() []detailField { + return []detailField{ + detailFieldID, + detailFieldTitle, + detailFieldUsername, + detailFieldPassword, + detailFieldURL, + detailFieldPath, + detailFieldTags, + detailFieldPasswordProfile, + detailFieldNotes, + detailFieldFields, + detailFieldHistoryIndex, + } +} + +func canonicalFocusID(id focusID) focusID { + switch { + case strings.HasPrefix(string(id), "breadcrumb:"): + return breadcrumbFocusID(0) + case strings.HasPrefix(string(id), "list:"): + return listFocusID(0) + case strings.HasPrefix(string(id), "detail:"): + return detailFocusID(detailFieldTitle) + default: + return id + } +} + +func focusIndex(id focusID) int { + _, value, ok := strings.Cut(string(id), ":") + if !ok { + return 0 + } + + index, err := strconv.Atoi(value) + if err != nil { + return 0 + } + return index +} diff --git a/ui_shortcuts.go b/ui_shortcuts.go index b3ee142..711e578 100644 --- a/ui_shortcuts.go +++ b/ui_shortcuts.go @@ -24,13 +24,19 @@ func (u *ui) processShortcuts(gtx layout.Context) { event.Op(gtx.Ops, u) for { ev, ok := gtx.Event( - key.Filter{Focus: u, Name: "F", Required: key.ModShortcut}, - key.Filter{Focus: u, Name: "S", Required: key.ModShortcut}, - key.Filter{Focus: u, Name: "L", Required: key.ModShortcut}, - key.Filter{Focus: u, Name: "N", Required: key.ModShortcut}, - key.Filter{Focus: u, Name: "U", Required: key.ModShortcut}, - key.Filter{Focus: u, Name: "P", Required: key.ModShortcut}, - key.Filter{Focus: u, Name: "O", Required: key.ModShortcut}, + key.Filter{Name: "F", Required: key.ModShortcut}, + key.Filter{Name: "S", Required: key.ModShortcut}, + key.Filter{Name: "L", Required: key.ModShortcut}, + key.Filter{Name: "N", Required: key.ModShortcut}, + key.Filter{Name: "U", Required: key.ModShortcut}, + key.Filter{Name: "P", Required: key.ModShortcut}, + key.Filter{Name: "O", Required: key.ModShortcut}, + key.Filter{Name: key.NameTab, Optional: key.ModShift}, + key.Filter{Name: key.NameLeftArrow}, + key.Filter{Name: key.NameRightArrow}, + key.Filter{Name: key.NameUpArrow}, + key.Filter{Name: key.NameDownArrow}, + key.Filter{Name: key.NameReturn}, ) if !ok { break @@ -41,28 +47,14 @@ func (u *ui) processShortcuts(gtx layout.Context) { continue } - switch ke.Name { - case "F": - _ = u.performShortcut(shortcutSearch) - case "S": - _ = u.performShortcut(shortcutSave) - case "L": - _ = u.performShortcut(shortcutLock) - case "N": - _ = u.performShortcut(shortcutNewEntry) - case "U": - _ = u.performShortcut(shortcutCopyUser) - case "P": - _ = u.performShortcut(shortcutCopyPassword) - case "O": - _ = u.performShortcut(shortcutCopyURL) - } + u.handleKeyPress(ke.Name, ke.Modifiers) } } func (u *ui) performShortcut(name string) error { switch name { case shortcutSearch: + u.keyboardFocus = focusSearch return nil case shortcutSave: return u.saveAction() @@ -72,6 +64,7 @@ func (u *ui) performShortcut(name string) error { u.state.SelectedEntryID = "" u.loadSelectedEntryIntoEditor() u.entryPath.SetText(strings.Join(u.currentPath, " / ")) + u.keyboardFocus = detailFocusID(detailFieldTitle) return nil case shortcutCopyUser: return u.copySelectedFieldAction(clipboard.TargetUsername) From f009573b4ace61453d0e55b71b5e9079d95f8742 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:21:52 -0700 Subject: [PATCH 03/13] Add gRPC vault lifecycle backend flow --- api/server.go | 68 +++++-- api/server_test.go | 289 ++++++++++++++++++++++++++++- proto/keepassgo/v1/keepassgo.pb.go | 22 ++- proto/keepassgo/v1/keepassgo.proto | 5 +- 4 files changed, 358 insertions(+), 26 deletions(-) diff --git a/api/server.go b/api/server.go index 157cec5..6aa6b34 100644 --- a/api/server.go +++ b/api/server.go @@ -4,15 +4,17 @@ import ( "context" "errors" "maps" + "os" "slices" - "sync" "strings" + "sync" "git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/passwords" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" - "git.julianfamily.org/keepassgo/webdav" + "git.julianfamily.org/keepassgo/session" "git.julianfamily.org/keepassgo/vault" + "git.julianfamily.org/keepassgo/webdav" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" @@ -36,6 +38,8 @@ type lifecycleBackend interface { Open(string, vault.MasterKey) error OpenRemote(webdav.Client, string, vault.MasterKey) error Save() error + Lock() error + Unlock(vault.MasterKey) error } func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server { @@ -57,8 +61,8 @@ func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionSt defer s.mu.RUnlock() return &keepassgov1.GetSessionStatusResponse{ - Locked: s.locked, - Dirty: s.dirty, + Locked: s.locked, + Dirty: s.dirty, EntryCount: uint32(len(s.model.Entries)), }, nil } @@ -70,12 +74,12 @@ func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest) key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)} if err := s.lifecycle.Open(req.GetPath(), key); err != nil { - return nil, status.Errorf(codes.Internal, "open vault: %v", err) + return nil, mapLifecycleError("open vault", err) } model, err := s.lifecycle.Current() if err != nil { - return nil, status.Errorf(codes.Internal, "load opened vault: %v", err) + return nil, mapLifecycleError("load opened vault", err) } s.mu.Lock() @@ -99,12 +103,12 @@ func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteV } key := vault.MasterKey{Password: req.GetMasterPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)} if err := s.lifecycle.OpenRemote(client, req.GetPath(), key); err != nil { - return nil, status.Errorf(codes.Internal, "open remote vault: %v", err) + return nil, mapLifecycleError("open remote vault", err) } model, err := s.lifecycle.Current() if err != nil { - return nil, status.Errorf(codes.Internal, "load opened remote vault: %v", err) + return nil, mapLifecycleError("load opened remote vault", err) } s.mu.Lock() @@ -122,7 +126,7 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) ( } if err := s.lifecycle.Save(); err != nil { - return nil, status.Errorf(codes.Internal, "save vault: %v", err) + return nil, mapLifecycleError("save vault", err) } s.mu.Lock() @@ -133,23 +137,59 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) ( } func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) { - s.mu.Lock() - defer s.mu.Unlock() + if s.lifecycle == nil { + return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured") + } + if err := s.lifecycle.Lock(); err != nil { + return nil, mapLifecycleError("lock vault", err) + } + + s.mu.Lock() s.locked = true + s.mu.Unlock() return &keepassgov1.LockVaultResponse{}, nil } -func (s *Server) UnlockVault(_ context.Context, _ *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) { - s.mu.Lock() - defer s.mu.Unlock() +func (s *Server) UnlockVault(_ context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) { + if s.lifecycle == nil { + return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured") + } + key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)} + if err := s.lifecycle.Unlock(key); err != nil { + return nil, mapLifecycleError("unlock vault", err) + } + + model, err := s.lifecycle.Current() + if err != nil { + return nil, mapLifecycleError("load unlocked vault", err) + } + + s.mu.Lock() + s.model = model s.locked = false + s.mu.Unlock() return &keepassgov1.UnlockVaultResponse{}, nil } +func mapLifecycleError(operation string, err error) error { + switch { + case errors.Is(err, os.ErrNotExist): + return status.Errorf(codes.NotFound, "%s: %v", operation, err) + case errors.Is(err, vault.ErrInvalidMasterKey): + return status.Errorf(codes.InvalidArgument, "%s: %v", operation, err) + case errors.Is(err, session.ErrLocked), errors.Is(err, session.ErrNoPath): + return status.Errorf(codes.FailedPrecondition, "%s: %v", operation, err) + case errors.Is(err, webdav.ErrConflict): + return status.Errorf(codes.Aborted, "%s: %v", operation, err) + default: + return status.Errorf(codes.Internal, "%s: %v", operation, err) + } +} + func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) { s.mu.RLock() defer s.mu.RUnlock() diff --git a/api/server_test.go b/api/server_test.go index d12494c..02500fb 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "net" + "os" "testing" "git.julianfamily.org/keepassgo/passwords" @@ -34,7 +35,21 @@ func TestVaultServiceRejectsRequestsWithoutBearerToken(t *testing.T) { func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) { t.Parallel() - client, _, cleanup := newTestClient(t) + lifecycle := &stubLifecycle{ + model: vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + }, + } + client, _, cleanup := newTestClientWithLifecycle(t, lifecycle) defer cleanup() ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") @@ -78,6 +93,82 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) { } } +func TestVaultServiceLockAndUnlockUseLifecycleBackend(t *testing.T) { + t.Parallel() + + lifecycle := &stubLifecycle{ + model: vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Remote Git", Path: []string{"Root", "Internet"}}, + }, + }, + unlockPassword: "correct horse battery staple", + unlockKeyFile: []byte("key-material"), + } + client, _, cleanup := newTestClientWithLifecycle(t, lifecycle) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{ + Path: "/tmp/test.kdbx", + Password: lifecycle.unlockPassword, + KeyFileData: lifecycle.unlockKeyFile, + }); err != nil { + t.Fatalf("OpenVault() error = %v", err) + } + + if _, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{}); err != nil { + t.Fatalf("LockVault() error = %v", err) + } + if !lifecycle.locked { + t.Fatal("LockVault() did not lock lifecycle backend") + } + + statusResp, err := client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{}) + if err != nil { + t.Fatalf("GetSessionStatus() after lock error = %v", err) + } + if !statusResp.Locked { + t.Fatal("GetSessionStatus().Locked = false, want true after lock") + } + + if _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{ + Password: "wrong password", + KeyFileData: lifecycle.unlockKeyFile, + }); status.Code(err) != codes.InvalidArgument { + t.Fatalf("UnlockVault(wrong password) code = %v, want %v", status.Code(err), codes.InvalidArgument) + } + + statusResp, err = client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{}) + if err != nil { + t.Fatalf("GetSessionStatus() after failed unlock error = %v", err) + } + if !statusResp.Locked { + t.Fatal("GetSessionStatus().Locked = false, want true after failed unlock") + } + + if _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{ + Password: lifecycle.unlockPassword, + KeyFileData: lifecycle.unlockKeyFile, + }); err != nil { + t.Fatalf("UnlockVault() error = %v", err) + } + if lifecycle.lastUnlockKey.Password != lifecycle.unlockPassword { + t.Fatalf("UnlockVault() password = %q, want %q", lifecycle.lastUnlockKey.Password, lifecycle.unlockPassword) + } + if !bytes.Equal(lifecycle.lastUnlockKey.KeyFileData, lifecycle.unlockKeyFile) { + t.Fatalf("UnlockVault() key data = %q, want %q", lifecycle.lastUnlockKey.KeyFileData, lifecycle.unlockKeyFile) + } + + listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) + if err != nil { + t.Fatalf("ListEntries() after unlock error = %v", err) + } + if len(listed.Entries) != 1 || listed.Entries[0].Title != "Remote Git" { + t.Fatalf("ListEntries().Entries = %#v, want Remote Git after unlock", listed.Entries) + } +} + func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) { t.Parallel() @@ -131,6 +222,143 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) { } } +func TestVaultServiceLifecycleMethodsRequireLifecycleBackend(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClient(t) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + + testCases := []struct { + name string + call func() error + }{ + { + name: "open", + call: func() error { + _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{Path: "/tmp/test.kdbx"}) + return err + }, + }, + { + name: "open_remote", + call: func() error { + _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{ + BaseUrl: "https://dav.example.com", + Path: "vaults/main.kdbx", + }) + return err + }, + }, + { + name: "save", + call: func() error { + _, err := client.SaveVault(ctx, &keepassgov1.SaveVaultRequest{}) + return err + }, + }, + { + name: "lock", + call: func() error { + _, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{}) + return err + }, + }, + { + name: "unlock", + call: func() error { + _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{}) + return err + }, + }, + } + + for _, tt := range testCases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + err := tt.call() + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("%s code = %v, want %v", tt.name, status.Code(err), codes.FailedPrecondition) + } + }) + } +} + +func TestVaultServiceLifecycleMethodsMapBackendErrors(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + call func(keepassgov1.VaultServiceClient, context.Context) error + err error + want codes.Code + }{ + { + name: "open not found", + call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error { + _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{Path: "/tmp/missing.kdbx"}) + return err + }, + err: os.ErrNotExist, + want: codes.NotFound, + }, + { + name: "open invalid master key", + call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error { + _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{Path: "/tmp/test.kdbx"}) + return err + }, + err: vault.ErrInvalidMasterKey, + want: codes.InvalidArgument, + }, + { + name: "save no path", + call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error { + _, err := client.SaveVault(ctx, &keepassgov1.SaveVaultRequest{}) + return err + }, + err: session.ErrNoPath, + want: codes.FailedPrecondition, + }, + { + name: "lock already locked", + call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error { + _, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{}) + return err + }, + err: session.ErrLocked, + want: codes.FailedPrecondition, + }, + { + name: "unlock invalid master key", + call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error { + _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{Password: "wrong"}) + return err + }, + err: vault.ErrInvalidMasterKey, + want: codes.InvalidArgument, + }, + } + + for _, tt := range testCases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + lifecycle := &stubLifecycle{err: tt.err} + client, _, cleanup := newTestClientWithLifecycle(t, lifecycle) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + err := tt.call(client, ctx) + if status.Code(err) != tt.want { + t.Fatalf("%s code = %v, want %v", tt.name, status.Code(err), tt.want) + } + }) + } +} + func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) { t.Parallel() @@ -503,7 +731,7 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa Path: []string{"Root", "Internet"}, }, }, - Path: []string{"Root", "Internet"}, + Path: []string{"Root", "Internet"}, }, { ID: "surveillance-console", @@ -560,7 +788,7 @@ func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepass server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token"))) clipboardWriter := &memoryClipboardWriter{} keepassgov1.RegisterVaultServiceServer(server, NewServerWithLifecycle( - vault.Model{}, + lifecycle.model, passwords.DefaultProfiles(), clipboardWriter, lifecycle, @@ -598,12 +826,16 @@ func (w *memoryClipboardWriter) WriteText(text string) error { } type stubLifecycle struct { - model vault.Model - openPath string - remoteBaseURL string - remotePath string - saved bool - locked bool + model vault.Model + openPath string + remoteBaseURL string + remotePath string + saved bool + locked bool + err error + unlockPassword string + unlockKeyFile []byte + lastUnlockKey vault.MasterKey } func (s *stubLifecycle) Current() (vault.Model, error) { @@ -614,17 +846,56 @@ func (s *stubLifecycle) Current() (vault.Model, error) { } func (s *stubLifecycle) Open(path string, _ vault.MasterKey) error { + if s.err != nil { + return s.err + } s.openPath = path + s.locked = false return nil } func (s *stubLifecycle) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error { + if s.err != nil { + return s.err + } s.remoteBaseURL = client.BaseURL s.remotePath = path + s.locked = false return nil } func (s *stubLifecycle) Save() error { + if s.err != nil { + return s.err + } s.saved = true return nil } + +func (s *stubLifecycle) Lock() error { + if s.err != nil { + return s.err + } + + s.locked = true + return nil +} + +func (s *stubLifecycle) Unlock(key vault.MasterKey) error { + if s.err != nil { + return s.err + } + if s.unlockPassword != "" && key.Password != s.unlockPassword { + return vault.ErrInvalidMasterKey + } + if s.unlockKeyFile != nil && !bytes.Equal(key.KeyFileData, s.unlockKeyFile) { + return vault.ErrInvalidMasterKey + } + + s.lastUnlockKey = vault.MasterKey{ + Password: key.Password, + KeyFileData: append([]byte(nil), key.KeyFileData...), + } + s.locked = false + return nil +} diff --git a/proto/keepassgo/v1/keepassgo.pb.go b/proto/keepassgo/v1/keepassgo.pb.go index 7136e87..8708e19 100644 --- a/proto/keepassgo/v1/keepassgo.pb.go +++ b/proto/keepassgo/v1/keepassgo.pb.go @@ -479,6 +479,8 @@ func (*LockVaultResponse) Descriptor() ([]byte, []int) { type UnlockVaultRequest struct { state protoimpl.MessageState `protogen:"open.v1"` + Password string `protobuf:"bytes,1,opt,name=password,proto3" json:"password,omitempty"` + KeyFileData []byte `protobuf:"bytes,2,opt,name=key_file_data,json=keyFileData,proto3" json:"key_file_data,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -513,6 +515,20 @@ func (*UnlockVaultRequest) Descriptor() ([]byte, []int) { return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{10} } +func (x *UnlockVaultRequest) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +func (x *UnlockVaultRequest) GetKeyFileData() []byte { + if x != nil { + return x.KeyFileData + } + return nil +} + type UnlockVaultResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -2364,8 +2380,10 @@ const file_proto_keepassgo_v1_keepassgo_proto_rawDesc = "" + "\x10SaveVaultRequest\"\x13\n" + "\x11SaveVaultResponse\"\x12\n" + "\x10LockVaultRequest\"\x13\n" + - "\x11LockVaultResponse\"\x14\n" + - "\x12UnlockVaultRequest\"\x15\n" + + "\x11LockVaultResponse\"T\n" + + "\x12UnlockVaultRequest\x12\x1a\n" + + "\bpassword\x18\x01 \x01(\tR\bpassword\x12\"\n" + + "\rkey_file_data\x18\x02 \x01(\fR\vkeyFileData\"\x15\n" + "\x13UnlockVaultResponse\">\n" + "\x12ListEntriesRequest\x12\x12\n" + "\x04path\x18\x01 \x03(\tR\x04path\x12\x14\n" + diff --git a/proto/keepassgo/v1/keepassgo.proto b/proto/keepassgo/v1/keepassgo.proto index 4005184..f8d1583 100644 --- a/proto/keepassgo/v1/keepassgo.proto +++ b/proto/keepassgo/v1/keepassgo.proto @@ -67,7 +67,10 @@ message LockVaultRequest {} message LockVaultResponse {} -message UnlockVaultRequest {} +message UnlockVaultRequest { + string password = 1; + bytes key_file_data = 2; +} message UnlockVaultResponse {} From a857f972ea72b839803b3f4fd753f10fe4ae9f6d Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:19:54 -0700 Subject: [PATCH 04/13] Add Segment 6 entry editor coverage --- appstate/state_test.go | 33 +++++++++++++++ main_test.go | 92 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/appstate/state_test.go b/appstate/state_test.go index a234619..5f3af72 100644 --- a/appstate/state_test.go +++ b/appstate/state_test.go @@ -473,6 +473,39 @@ func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) { } } +func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{model: vault.Model{ + Entries: []vault.Entry{ + {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, + }, + }} + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + SelectedEntryID: "vault-console", + } + + if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil { + t.Fatalf("MoveSelectedEntry() error = %v", err) + } + + oldPath := sess.model.EntriesInPath([]string{"Root", "Internet"}) + if len(oldPath) != 0 { + t.Fatalf("EntriesInPath(Root/Internet) = %#v, want empty after move", oldPath) + } + + newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"}) + if len(newPath) != 1 || newPath[0].ID != "vault-console" { + t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved vault-console entry", newPath) + } + + if !state.Dirty { + t.Fatal("Dirty = false, want true after move") + } +} + func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) { t.Parallel() diff --git a/main_test.go b/main_test.go index d2a5b55..fe22380 100644 --- a/main_test.go +++ b/main_test.go @@ -592,6 +592,98 @@ func TestUISavesDuplicatesDeletesAndRestoresEntriesFromTheEditor(t *testing.T) { } } +func TestUICreatesEntryWithAllSupportedEditorFields(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + + u.entryID.SetText("bellagio") + u.entryTitle.SetText("Bellagio") + u.entryUsername.SetText("rustyryan") + u.entryPassword.SetText("token-1") + u.entryURL.SetText("https://bellagio.example.invalid") + u.entryNotes.SetText("Registrar account") + u.entryTags.SetText("dns, registrar") + u.entryPath.SetText("Root / Internet") + u.entryFields.SetText("Environment=prod\nAccount ID=12345") + + if err := u.saveEntryAction(); err != nil { + t.Fatalf("saveEntryAction() create error = %v", err) + } + + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) { + t.Fatalf("filteredTitles() = %v, want [Bellagio]", got) + } + + item, ok := u.selectedEntry() + if !ok { + t.Fatal("selectedEntry() ok = false, want created entry") + } + if item.Title != "Bellagio" || item.Username != "rustyryan" || item.Password != "token-1" || item.URL != "https://bellagio.example.invalid" { + t.Fatalf("selectedEntry() = %#v, want created Bellagio credentials", item) + } + if item.Notes != "Registrar account" { + t.Fatalf("selectedEntry().Notes = %q, want %q", item.Notes, "Registrar account") + } + if !slices.Equal(item.Tags, []string{"dns", "registrar"}) { + t.Fatalf("selectedEntry().Tags = %v, want [dns registrar]", item.Tags) + } + if item.Fields["Environment"] != "prod" || item.Fields["Account ID"] != "12345" { + t.Fatalf("selectedEntry().Fields = %#v, want parsed custom fields", item.Fields) + } +} + +func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + u.state.SelectedEntryID = "vault-console" + u.loadSelectedEntryIntoEditor() + u.entryPath.SetText("Root / Infrastructure") + + if err := u.saveEntryAction(); err != nil { + t.Fatalf("saveEntryAction() move error = %v", err) + } + + u.currentPath = []string{"Root", "Internet"} + u.filter() + if got := u.filteredTitles(); len(got) != 0 { + t.Fatalf("filteredTitles() in old path = %v, want empty after move", got) + } + + u.currentPath = []string{"Root", "Infrastructure"} + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) { + t.Fatalf("filteredTitles() in new path = %v, want [Vault Console]", got) + } + + item, ok := u.selectedEntry() + if !ok { + t.Fatal("selectedEntry() ok = false, want moved entry") + } + if !slices.Equal(item.Path, []string{"Root", "Infrastructure"}) { + t.Fatalf("selectedEntry().Path = %v, want [Root Infrastructure]", item.Path) + } +} + func TestUITemplateAndAttachmentActionsWorkThroughEditor(t *testing.T) { t.Parallel() From 57f6ae658d9d61662f05323fb249c025eda30ec7 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:26:14 -0700 Subject: [PATCH 05/13] Add regression coverage for KDBX reopen cycles --- session/session_test.go | 129 ++++++++++++++++++++++++++++++++++++++++ vault/kdbx.go | 126 ++++++++++++++++++++++++++++----------- vault/kdbx_test.go | 109 +++++++++++++++++++++++++++++++++ 3 files changed, 330 insertions(+), 34 deletions(-) diff --git a/session/session_test.go b/session/session_test.go index 7324da5..748c009 100644 --- a/session/session_test.go +++ b/session/session_test.go @@ -537,3 +537,132 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) { t.Fatalf("saved KDF UUID = %x, want %x", reloaded.Header.FileHeaders.KdfParameters.UUID, db.Header.FileHeaders.KdfParameters.UUID) } } + +func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + model := vault.Model{ + Entries: []vault.Entry{ + { + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-2", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + Attachments: map[string][]byte{ + "token.txt": []byte("secret attachment contents"), + }, + History: []vault.Entry{ + { + ID: "entry-1-history-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + }, + }, + Templates: []vault.Entry{ + { + ID: "tpl-1", + Title: "Website Login", + Username: "template-user", + Password: "template-password", + Path: []string{"Templates", "Web"}, + }, + }, + RecycleBin: []vault.Entry{ + { + ID: "deleted-1", + Title: "Retired Entry", + Username: "archived-user", + Password: "retired-token", + Path: []string{"Root", "Archive"}, + }, + }, + Groups: [][]string{ + {"Root", "Archive"}, + {"Root", "Empty Group"}, + {"Templates", "Web"}, + }, + } + + var remoteBytes bytes.Buffer + if err := vault.SaveKDBXWithKey(&remoteBytes, model, key); err != nil { + t.Fatalf("SaveKDBXWithKey(seed remote) error = %v", err) + } + + etag := "\"v1\"" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("ETag", etag) + _, _ = w.Write(remoteBytes.Bytes()) + case http.MethodPut: + if got := r.Header.Get("If-Match"); got != etag { + t.Fatalf("If-Match header = %q, want %q", got, etag) + } + + payload, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll(PUT body) error = %v", err) + } + + remoteBytes.Reset() + if _, err := remoteBytes.Write(payload); err != nil { + t.Fatalf("Write(remoteBytes) error = %v", err) + } + + etag = "\"v2\"" + w.Header().Set("ETag", etag) + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer server.Close() + + client := webdav.Client{BaseURL: server.URL} + + var sess Manager + if err := sess.OpenRemote(client, "vaults/main.kdbx", key); err != nil { + t.Fatalf("OpenRemote() error = %v", err) + } + if err := sess.SaveRemote(); err != nil { + t.Fatalf("SaveRemote() error = %v", err) + } + + var reopened Manager + if err := reopened.OpenRemote(client, "vaults/main.kdbx", key); err != nil { + t.Fatalf("reopen OpenRemote() error = %v", err) + } + + current, err := reopened.Current() + if err != nil { + t.Fatalf("Current() after reopen error = %v", err) + } + + got := current.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 1 { + t.Fatalf("len(EntriesInPath(Root/Internet)) after reopen = %d, want 1", len(got)) + } + if got[0].ID != "entry-1" { + t.Fatalf("entry ID after remote reopen = %q, want %q", got[0].ID, "entry-1") + } + if len(got[0].History) != 1 || got[0].History[0].ID != "entry-1-history-1" { + t.Fatalf("History after remote reopen = %#v, want stable history ID entry-1-history-1", got[0].History) + } + if string(got[0].Attachments["token.txt"]) != "secret attachment contents" { + t.Fatalf("attachment after remote reopen = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents") + } + if len(current.Templates) != 1 || current.Templates[0].Path[1] != "Web" { + t.Fatalf("Templates after remote reopen = %#v, want Website Login in Templates/Web", current.Templates) + } + if len(current.RecycleBin) != 1 || current.RecycleBin[0].Path[1] != "Archive" { + t.Fatalf("RecycleBin after remote reopen = %#v, want retired entry in Root/Archive", current.RecycleBin) + } +} diff --git a/vault/kdbx.go b/vault/kdbx.go index 93f4ab9..b8ffd14 100644 --- a/vault/kdbx.go +++ b/vault/kdbx.go @@ -23,8 +23,8 @@ type KDBXConfig struct { var ErrInvalidMasterKey = errors.New("invalid master key") const ( - templatesRoot = "Templates" - recycleBinRoot = "Recycle Bin" + templatesRoot = "Templates" + recycleBinRoot = "Recycle Bin" keepassGOIDField = "KeePassGO-ID" ) @@ -46,33 +46,29 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config * return err } - header := gokeepasslib.NewHeader() + db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4()) + db.Credentials = credentials + db.Content.Meta = gokeepasslib.NewMetaData() + db.Content.Root = &gokeepasslib.RootData{} if config != nil && config.Header != nil { - header = cloneHeader(config.Header) + db.Header = cloneHeader(config.Header) + db.Hashes = gokeepasslib.NewHashes(db.Header) } - content := &gokeepasslib.DBContent{ - Meta: gokeepasslib.NewMetaData(), - Root: &gokeepasslib.RootData{}, - } - if header.IsKdbx4() { + if db.Header.IsKdbx4() { if config != nil && config.InnerHeader != nil { - content.InnerHeader = cloneInnerHeader(config.InnerHeader) - } else { - content.InnerHeader = &gokeepasslib.InnerHeader{ + db.Content.InnerHeader = cloneInnerHeader(config.InnerHeader) + db.Content.InnerHeader.Binaries = nil + } else if db.Content.InnerHeader == nil { + db.Content.InnerHeader = &gokeepasslib.InnerHeader{ InnerRandomStreamID: gokeepasslib.ChaChaStreamID, InnerRandomStreamKey: randomBytes(64), } } + } else { + db.Content.InnerHeader = nil } - - db := &gokeepasslib.Database{ - Header: header, - Credentials: credentials, - Content: content, - Hashes: gokeepasslib.NewHashes(header), - } - db.Content.Root.Groups = buildGroupTree(db, entriesForPersistence(model)) - db.Content.Root.DeletedObjects = marshalDeletedObjects(model.RecycleBin) + db.Content.Root.Groups = buildGroupTree(db, model) + db.Content.Root.DeletedObjects = nil if err := db.LockProtectedEntries(); err != nil { return fmt.Errorf("lock protected entries: %w", err) @@ -87,20 +83,21 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config * func appendGroupEntries(model *Model, db *gokeepasslib.Database, group gokeepasslib.Group, path []string) { path = append(clonePath(path), group.Name) + model.CreateGroup(path[:len(path)-1], group.Name) for _, entry := range group.Entries { appendModelEntry(model, Entry{ - ID: extractEntryID(entry), - Title: entry.GetTitle(), - Username: entry.GetContent("UserName"), - Password: entry.GetPassword(), - URL: entry.GetContent("URL"), - Notes: entry.GetContent("Notes"), - Tags: splitTags(entry.Tags), - Fields: extractCustomFields(entry), + ID: extractEntryID(entry), + Title: entry.GetTitle(), + Username: entry.GetContent("UserName"), + Password: entry.GetPassword(), + URL: entry.GetContent("URL"), + Notes: entry.GetContent("Notes"), + Tags: splitTags(entry.Tags), + Fields: extractCustomFields(entry), Attachments: extractAttachments(db, entry), - History: extractHistory(db, entry, path), - Path: clonePath(path), + History: extractHistory(db, entry, path), + Path: clonePath(path), }) } @@ -207,7 +204,7 @@ func extractHistory(db *gokeepasslib.Database, entry gokeepasslib.Entry, path [] for _, item := range entry.Histories { for _, historical := range item.Entries { history = append(history, Entry{ - ID: marshalUUID(historical.UUID), + ID: extractEntryID(historical), Title: historical.GetTitle(), Username: historical.GetContent("UserName"), Password: historical.GetPassword(), @@ -235,7 +232,8 @@ type MasterKey struct { KeyFileData []byte } -func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Group { +func buildGroupTree(db *gokeepasslib.Database, model Model) []gokeepasslib.Group { + entries := entriesForPersistence(model) root := &groupNode{children: map[string]*groupNode{}} for _, entry := range entries { node := root @@ -250,6 +248,18 @@ func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.G } node.entries = append(node.entries, entry) } + for _, path := range groupPathsForPersistence(model, entries) { + node := root + for _, segment := range path { + if node.children[segment] == nil { + node.children[segment] = &groupNode{ + name: segment, + children: map[string]*groupNode{}, + } + } + node = node.children[segment] + } + } groups := marshalGroups(db, root) if len(groups) > 0 { @@ -261,6 +271,31 @@ func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.G return []gokeepasslib.Group{group} } +func groupPathsForPersistence(model Model, entries []Entry) [][]string { + seen := map[string]bool{} + var groups [][]string + appendPath := func(path []string) { + key := strings.Join(path, "\x00") + if seen[key] { + return + } + seen[key] = true + groups = append(groups, slices.Clone(path)) + } + + for _, entry := range entries { + for i := 1; i <= len(entry.Path); i++ { + appendPath(entry.Path[:i]) + } + } + for _, path := range model.Groups { + for i := 1; i <= len(path); i++ { + appendPath(path[:i]) + } + } + return groups +} + func LoadKDBXWithKey(r io.Reader, key MasterKey) (Model, error) { model, _, err := LoadKDBXWithConfig(r, key) return model, err @@ -407,7 +442,7 @@ func isInvalidCredentialError(err error) bool { func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Group { names := slices.Collect(maps.Keys(node.children)) - slices.Sort(names) + slices.SortFunc(names, compareGroupNames) var groups []gokeepasslib.Group for _, name := range names { @@ -422,6 +457,29 @@ func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Gr return groups } +func compareGroupNames(a, b string) int { + switch { + case a == b: + return 0 + case a == "Root": + return -1 + case b == "Root": + return 1 + case a == templatesRoot: + return -1 + case b == templatesRoot: + return 1 + case a == recycleBinRoot: + return 1 + case b == recycleBinRoot: + return -1 + case a < b: + return -1 + default: + return 1 + } +} + func marshalEntries(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Entry { slices.SortFunc(entries, func(a, b Entry) int { switch { diff --git a/vault/kdbx_test.go b/vault/kdbx_test.go index a147b64..f856c72 100644 --- a/vault/kdbx_test.go +++ b/vault/kdbx_test.go @@ -3,6 +3,7 @@ package vault import ( "bytes" "errors" + "slices" "testing" "github.com/tobischo/gokeepasslib/v3" @@ -602,6 +603,114 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) { } } +func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) { + t.Parallel() + + model := Model{ + Entries: []Entry{ + { + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-2", + URL: "https://vault.crew.example.invalid", + Notes: "Current credential", + Path: []string{"Root", "Internet"}, + Attachments: map[string][]byte{ + "token.txt": []byte("secret attachment contents"), + }, + History: []Entry{ + { + ID: "entry-1-history-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Notes: "Original credential", + Path: []string{"Root", "Internet"}, + }, + }, + }, + }, + Templates: []Entry{ + { + ID: "tpl-1", + Title: "Website Login", + Username: "template-user", + Password: "template-password", + Path: []string{"Templates", "Web"}, + }, + }, + RecycleBin: []Entry{ + { + ID: "deleted-1", + Title: "Retired Entry", + Username: "archived-user", + Password: "retired-token", + Path: []string{"Root", "Archive"}, + }, + }, + Groups: [][]string{ + {"Root", "Archive"}, + {"Root", "Empty Group"}, + {"Templates", "Web"}, + }, + } + + var encoded bytes.Buffer + if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil { + t.Fatalf("SaveKDBX(first cycle) error = %v", err) + } + + reopened, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple") + if err != nil { + t.Fatalf("LoadKDBX(first cycle) error = %v", err) + } + + encoded.Reset() + if err := SaveKDBX(&encoded, reopened, "correct horse battery staple"); err != nil { + t.Fatalf("SaveKDBX(second cycle) error = %v", err) + } + + reopened, err = LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple") + if err != nil { + t.Fatalf("LoadKDBX(second cycle) error = %v", err) + } + + got := reopened.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 1 { + t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got)) + } + if got[0].ID != "entry-1" { + t.Fatalf("entry ID after reopen cycles = %q, want %q", got[0].ID, "entry-1") + } + if len(got[0].History) != 1 { + t.Fatalf("len(History) after reopen cycles = %d, want 1", len(got[0].History)) + } + if got[0].History[0].ID != "entry-1-history-1" { + t.Fatalf("history ID after reopen cycles = %q, want %q", got[0].History[0].ID, "entry-1-history-1") + } + if string(got[0].Attachments["token.txt"]) != "secret attachment contents" { + t.Fatalf("attachment after reopen cycles = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents") + } + + if len(reopened.Templates) != 1 || reopened.Templates[0].Path[1] != "Web" { + t.Fatalf("Templates after reopen cycles = %#v, want Website Login in Templates/Web", reopened.Templates) + } + if len(reopened.RecycleBin) != 1 || reopened.RecycleBin[0].Path[1] != "Archive" { + t.Fatalf("RecycleBin after reopen cycles = %#v, want recycled entry in Root/Archive", reopened.RecycleBin) + } + + rootGroups := reopened.ChildGroups([]string{"Root"}) + if !slices.Equal(rootGroups, []string{"Archive", "Empty Group", "Internet"}) { + t.Fatalf("ChildGroups(Root) after reopen cycles = %v, want [Archive Empty Group Internet]", rootGroups) + } + templateGroups := reopened.ChildGroups([]string{"Templates"}) + if !slices.Equal(templateGroups, []string{"Web"}) { + t.Fatalf("ChildGroups(Templates) after reopen cycles = %v, want [Web]", templateGroups) + } +} + func mustGroup(name string, children ...any) gokeepasslib.Group { group := gokeepasslib.NewGroup() group.Name = name From 40fd1bfde9344dd929e97430f2e45b9459727f02 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:26:49 -0700 Subject: [PATCH 06/13] Remove dead deleted-object helper --- vault/kdbx.go | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/vault/kdbx.go b/vault/kdbx.go index b8ffd14..ff63bda 100644 --- a/vault/kdbx.go +++ b/vault/kdbx.go @@ -535,23 +535,6 @@ func marshalEntry(db *gokeepasslib.Database, entry Entry) gokeepasslib.Entry { return item } -func marshalDeletedObjects(entries []Entry) []gokeepasslib.DeletedObjectData { - if len(entries) == 0 { - return nil - } - - deletionTime := w.Now() - out := make([]gokeepasslib.DeletedObjectData, 0, len(entries)) - for _, entry := range entries { - out = append(out, gokeepasslib.DeletedObjectData{ - UUID: uuidForEntryID(entry.ID), - DeletionTime: &deletionTime, - }) - } - - return out -} - func uuidForEntryID(id string) gokeepasslib.UUID { if id != "" { var uuid gokeepasslib.UUID From 6748d31f87da98ad196c3108a2d7f8348e937264 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:28:17 -0700 Subject: [PATCH 07/13] Add keyboard-first accessibility behavior --- main.go | 43 +++--- main_test.go | 175 +++++++++++++++++++++ ui_accessibility.go | 112 ++++++++++++++ ui_forms.go | 34 +++-- ui_keyboard.go | 361 ++++++++++++++++++++++++++++++++++++++++++++ ui_shortcuts.go | 39 ++--- 6 files changed, 712 insertions(+), 52 deletions(-) create mode 100644 ui_accessibility.go create mode 100644 ui_keyboard.go diff --git a/main.go b/main.go index ec00a42..056a420 100644 --- a/main.go +++ b/main.go @@ -138,6 +138,7 @@ type ui struct { loadingMessage string statusMessage string errorMessage string + keyboardFocus focusID } var ( @@ -213,6 +214,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession) *ui { u.eyeOffIcon, _ = widget.NewIcon(icons.ActionVisibilityOff) u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy) u.passwordProfile.SetText("strong") + u.keyboardFocus = focusSearch u.filter() return u } @@ -772,7 +774,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { gtx.Constraints.Min.X = gtx.Constraints.Max.X } - return outlinedField(gtx, func(gtx layout.Context) layout.Dimensions { + return outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions { editor := material.Editor(u.theme, &u.search, "Search vault") editor.Color = u.theme.Palette.Fg editor.HintColor = mutedColor @@ -876,7 +878,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item ) }) } - if item.ID == u.state.SelectedEntryID { + if item.ID == u.state.SelectedEntryID || u.isFocused(listFocusID(idx)) { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { size := gtx.Constraints.Min @@ -886,8 +888,14 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item if size.Y == 0 { size.Y = gtx.Constraints.Max.Y } - paint.FillShape(gtx.Ops, selectedColor, clip.Rect{Max: size}.Op()) - paint.FillShape(gtx.Ops, selectedEdge, clip.Rect{Max: image.Pt(4, size.Y)}.Op()) + fillColor := selectedColor + edgeColor := selectedEdge + if u.isFocused(listFocusID(idx)) && item.ID != u.state.SelectedEntryID { + fillColor = color.NRGBA{R: 235, G: 241, B: 238, A: 255} + edgeColor = accentColor + } + paint.FillShape(gtx.Ops, fillColor, clip.Rect{Max: size}.Op()) + paint.FillShape(gtx.Ops, edgeColor, clip.Rect{Max: image.Pt(4, size.Y)}.Op()) return layout.Dimensions{Size: size} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { @@ -1104,8 +1112,7 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { u.filter() } btn := material.Button(u.theme, &u.breadcrumbs[index], label) - btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255} - btn.Color = accentColor + btn.Background, btn.Color = buttonFocusColors(u.isFocused(breadcrumbFocusID(index))) btn.TextSize = unit.Sp(12) btn.Inset = layout.Inset{Top: 6, Bottom: 6, Left: 10, Right: 10} return btn.Layout(gtx) @@ -1213,14 +1220,14 @@ func compactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { }) } -func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions { - border := color.NRGBA{R: 202, G: 194, B: 180, A: 255} +func outlinedFieldState(gtx layout.Context, focused bool, w layout.Widget) layout.Dimensions { + appearance := fieldFocusAppearance(gtx.Metric, focused) size := gtx.Constraints.Min if size.X == 0 { size.X = gtx.Constraints.Max.X } if size.Y == 0 { - size.Y = gtx.Dp(unit.Dp(44)) + size.Y = appearance.MinHeight } gtx.Constraints.Min = size return layout.Stack{}.Layout(gtx, @@ -1229,10 +1236,13 @@ func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions { return layout.Dimensions{Size: size} }), layout.Expanded(func(gtx layout.Context) layout.Dimensions { - paint.FillShape(gtx.Ops, border, clip.Rect{Max: image.Pt(size.X, 1)}.Op()) - paint.FillShape(gtx.Ops, border, clip.Rect{Min: image.Pt(0, size.Y-1), Max: image.Pt(size.X, size.Y)}.Op()) - paint.FillShape(gtx.Ops, border, clip.Rect{Max: image.Pt(1, size.Y)}.Op()) - paint.FillShape(gtx.Ops, border, clip.Rect{Min: image.Pt(size.X-1, 0), Max: image.Pt(size.X, size.Y)}.Op()) + return drawFocusOutline(gtx, appearance, size) + }), + layout.Expanded(func(gtx layout.Context) layout.Dimensions { + paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(size.X, 1)}.Op()) + paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(0, size.Y-1), Max: image.Pt(size.X, size.Y)}.Op()) + paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Max: image.Pt(1, size.Y)}.Op()) + paint.FillShape(gtx.Ops, appearance.BorderColor, clip.Rect{Min: image.Pt(size.X-1, 0), Max: image.Pt(size.X, size.Y)}.Op()) return layout.Dimensions{Size: size} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { @@ -1245,8 +1255,8 @@ func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions { if dims.Size.Y < min.Y { dims.Size.Y = min.Y } - if dims.Size.Y < gtx.Dp(unit.Dp(44)) { - dims.Size.Y = gtx.Dp(unit.Dp(44)) + if dims.Size.Y < appearance.MinHeight { + dims.Size.Y = appearance.MinHeight } return dims }), @@ -1255,8 +1265,7 @@ func outlinedField(gtx layout.Context, w layout.Widget) layout.Dimensions { func tonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string) layout.Dimensions { btn := material.Button(th, click, label) - btn.Background = color.NRGBA{R: 231, G: 239, B: 235, A: 255} - btn.Color = accentColor + btn.Background, btn.Color = buttonFocusColors(false) btn.CornerRadius = unit.Dp(10) btn.TextSize = unit.Sp(15) return btn.Layout(gtx) diff --git a/main_test.go b/main_test.go index fe22380..d0e4136 100644 --- a/main_test.go +++ b/main_test.go @@ -10,6 +10,9 @@ import ( "slices" "testing" + "gioui.org/io/key" + "gioui.org/unit" + "git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/session" "git.julianfamily.org/keepassgo/vault" @@ -849,6 +852,178 @@ func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) { } } +func TestUIKeyboardNavigationMovesAcrossBreadcrumbsListAndDetail(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "bellagio", + Title: "Bellagio", + Username: "rustyryan", + Path: []string{"Root", "Internet"}, + }, + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + + if got := u.keyboardFocus; got != focusSearch { + t.Fatalf("keyboardFocus = %q, want %q", got, focusSearch) + } + + u.handleKeyPress(key.NameTab, 0) + if got := u.keyboardFocus; got != breadcrumbFocusID(0) { + t.Fatalf("keyboardFocus after Tab = %q, want %q", got, breadcrumbFocusID(0)) + } + + u.handleKeyPress(key.NameTab, 0) + if got := u.keyboardFocus; got != listFocusID(0) { + t.Fatalf("keyboardFocus after second Tab = %q, want %q", got, listFocusID(0)) + } + if got := u.state.SelectedEntryID; got != "bellagio" { + t.Fatalf("SelectedEntryID after list focus = %q, want %q", got, "bellagio") + } + + u.handleKeyPress(key.NameDownArrow, 0) + if got := u.keyboardFocus; got != listFocusID(1) { + t.Fatalf("keyboardFocus after Down = %q, want %q", got, listFocusID(1)) + } + if got := u.state.SelectedEntryID; got != "vault-console" { + t.Fatalf("SelectedEntryID after Down = %q, want %q", got, "vault-console") + } + + u.handleKeyPress(key.NameTab, 0) + if got := u.keyboardFocus; got != detailFocusID(detailFieldTitle) { + t.Fatalf("keyboardFocus after detail Tab = %q, want %q", got, detailFocusID(detailFieldTitle)) + } +} + +func TestUIKeyboardNavigationActivatesBreadcrumbs(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + u.keyboardFocus = breadcrumbFocusID(0) + + u.handleKeyPress(key.NameRightArrow, 0) + if got := u.keyboardFocus; got != breadcrumbFocusID(1) { + t.Fatalf("keyboardFocus after Right = %q, want %q", got, breadcrumbFocusID(1)) + } + + u.handleKeyPress(key.NameReturn, 0) + if got := u.currentPath; !slices.Equal(got, []string{"Root"}) { + t.Fatalf("currentPath after breadcrumb activation = %v, want [Root]", got) + } +} + +func TestUIKeyboardShortcutsMoveFocusForSearchAndNewEntry(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + u.state.SelectedEntryID = "vault-console" + u.loadSelectedEntryIntoEditor() + u.keyboardFocus = listFocusID(0) + + u.handleKeyPress("F", key.ModShortcut) + if got := u.keyboardFocus; got != focusSearch { + t.Fatalf("keyboardFocus after shortcut search = %q, want %q", got, focusSearch) + } + + u.handleKeyPress("N", key.ModShortcut) + if got := u.state.SelectedEntryID; got != "" { + t.Fatalf("SelectedEntryID after shortcut new-entry = %q, want empty", got) + } + if got := u.keyboardFocus; got != detailFocusID(detailFieldTitle) { + t.Fatalf("keyboardFocus after shortcut new-entry = %q, want %q", got, detailFocusID(detailFieldTitle)) + } +} + +func TestUIAccessibilityLabelsDescribeFocusableControls(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + + if got := u.accessibilityLabel(focusSearch); got != "Search vault" { + t.Fatalf("accessibilityLabel(search) = %q, want %q", got, "Search vault") + } + if got := u.accessibilityLabel(breadcrumbFocusID(1)); got != "Navigate to Root" { + t.Fatalf("accessibilityLabel(breadcrumb) = %q, want %q", got, "Navigate to Root") + } + if got := u.accessibilityLabel(listFocusID(0)); got != "Select entry Vault Console" { + t.Fatalf("accessibilityLabel(list) = %q, want %q", got, "Select entry Vault Console") + } + if got := u.accessibilityLabel(detailFocusID(detailFieldPassword)); got != "Edit Password" { + t.Fatalf("accessibilityLabel(detail password) = %q, want %q", got, "Edit Password") + } +} + +func TestFieldFocusAppearanceScalesForHighDPI(t *testing.T) { + t.Parallel() + + lo := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, true) + hi := fieldFocusAppearance(unit.Metric{PxPerDp: 2.5, PxPerSp: 2.5}, true) + unfocused := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, false) + + if got := lo.MinHeight; got != 44 { + t.Fatalf("fieldFocusAppearance(low).MinHeight = %d, want 44", got) + } + if got := hi.MinHeight; got != 110 { + t.Fatalf("fieldFocusAppearance(high).MinHeight = %d, want 110", got) + } + if got := lo.OutlineWidth; got < 2 { + t.Fatalf("fieldFocusAppearance(low).OutlineWidth = %d, want >= 2", got) + } + if hi.OutlineWidth <= lo.OutlineWidth { + t.Fatalf("fieldFocusAppearance(high).OutlineWidth = %d, want > %d", hi.OutlineWidth, lo.OutlineWidth) + } + if lo.OutlineColor == unfocused.OutlineColor { + t.Fatalf("fieldFocusAppearance().OutlineColor focused = %#v, want distinct from unfocused %#v", lo.OutlineColor, unfocused.OutlineColor) + } +} + func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) { t.Parallel() diff --git a/ui_accessibility.go b/ui_accessibility.go new file mode 100644 index 0000000..5ee2289 --- /dev/null +++ b/ui_accessibility.go @@ -0,0 +1,112 @@ +package main + +import ( + "fmt" + "image" + "image/color" + "strings" + + "gioui.org/layout" + "gioui.org/op/clip" + "gioui.org/op/paint" + "gioui.org/unit" +) + +type focusAppearance struct { + BorderColor color.NRGBA + OutlineColor color.NRGBA + OutlineWidth int + MinHeight int +} + +func fieldFocusAppearance(metric unit.Metric, focused bool) focusAppearance { + appearance := focusAppearance{ + BorderColor: color.NRGBA{R: 202, G: 194, B: 180, A: 255}, + OutlineColor: color.NRGBA{A: 0}, + OutlineWidth: max(1, metric.Dp(unit.Dp(1))), + MinHeight: metric.Dp(unit.Dp(44)), + } + if focused { + appearance.BorderColor = accentColor + appearance.OutlineColor = color.NRGBA{R: 28, G: 83, B: 63, A: 72} + appearance.OutlineWidth = max(2, metric.Dp(unit.Dp(2))) + } + return appearance +} + +func buttonFocusColors(focused bool) (background color.NRGBA, text color.NRGBA) { + background = color.NRGBA{R: 231, G: 239, B: 235, A: 255} + text = accentColor + if focused { + background = color.NRGBA{R: 214, G: 229, B: 221, A: 255} + } + return background, text +} + +func (u *ui) accessibilityLabel(id focusID) string { + switch { + case id == focusSearch: + return "Search vault" + case strings.HasPrefix(string(id), "breadcrumb:"): + index := focusIndex(id) + crumbs := u.breadcrumbLabels() + if index >= 0 && index < len(crumbs) { + return fmt.Sprintf("Navigate to %s", crumbs[index]) + } + case strings.HasPrefix(string(id), "list:"): + index := focusIndex(id) + if index >= 0 && index < len(u.visible) { + return fmt.Sprintf("Select entry %s", u.visible[index].Title) + } + case strings.HasPrefix(string(id), "detail:"): + name := strings.TrimPrefix(string(id), "detail:") + return fmt.Sprintf("Edit %s", detailFieldLabel(detailField(name))) + } + return "" +} + +func drawFocusOutline(gtx layout.Context, appearance focusAppearance, size image.Point) layout.Dimensions { + if appearance.OutlineColor.A == 0 || appearance.OutlineWidth <= 0 { + return layout.Dimensions{Size: size} + } + + width := appearance.OutlineWidth + paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(size.X, width)}.Op()) + paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(0, size.Y-width), Max: image.Pt(size.X, size.Y)}.Op()) + paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Max: image.Pt(width, size.Y)}.Op()) + paint.FillShape(gtx.Ops, appearance.OutlineColor, clip.Rect{Min: image.Pt(size.X-width, 0), Max: image.Pt(size.X, size.Y)}.Op()) + return layout.Dimensions{Size: size} +} + +func (u *ui) isFocused(id focusID) bool { + return u.keyboardFocus == id +} + +func detailFieldLabel(field detailField) string { + switch field { + case detailFieldID: + return "ID" + case detailFieldTitle: + return "Title" + case detailFieldUsername: + return "Username" + case detailFieldPassword: + return "Password" + case detailFieldURL: + return "URL" + case detailFieldPath: + return "Path" + case detailFieldTags: + return "Tags" + case detailFieldPasswordProfile: + return "Password Profile" + case detailFieldNotes: + return "Notes" + case detailFieldFields: + return "Custom Fields" + case detailFieldHistoryIndex: + return "History Index" + default: + return strings.ReplaceAll(string(field), "-", " ") + } +} diff --git a/ui_forms.go b/ui_forms.go index d0222f9..3ca93e4 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -111,27 +111,27 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions { func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(labeledEditor(u.theme, "ID", &u.entryID, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "ID", &u.entryID, false, u.isFocused(detailFocusID(detailFieldID)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Title", &u.entryTitle, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Title", &u.entryTitle, false, u.isFocused(detailFocusID(detailFieldTitle)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Username", &u.entryUsername, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Username", &u.entryUsername, false, u.isFocused(detailFocusID(detailFieldUsername)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Password", &u.entryPassword, true)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Password", &u.entryPassword, true, u.isFocused(detailFocusID(detailFieldPassword)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "URL", &u.entryURL, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "URL", &u.entryURL, false, u.isFocused(detailFocusID(detailFieldURL)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Path", &u.entryPath, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Path", &u.entryPath, false, u.isFocused(detailFocusID(detailFieldPath)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Tags", &u.entryTags, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Tags", &u.entryTags, false, u.isFocused(detailFocusID(detailFieldTags)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Password Profile", &u.passwordProfile, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Password Profile", &u.passwordProfile, false, u.isFocused(detailFocusID(detailFieldPasswordProfile)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Notes", &u.entryNotes, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditor(u.theme, "Custom Fields (key=value)", &u.entryFields, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "Custom Fields (key=value)", &u.entryFields, false, u.isFocused(detailFocusID(detailFieldFields)))), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(labeledEditor(u.theme, "History Index", &u.historyIndex, false)), + layout.Rigid(labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { switch u.state.Section { @@ -211,6 +211,16 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { } func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sensitive bool) layout.Widget { + return labeledEditorWithFocus(th, label, editor, sensitive, false) +} + +func labeledEditorWithFocus( + th *material.Theme, + label string, + editor *widget.Editor, + sensitive bool, + focused bool, +) layout.Widget { return func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -219,7 +229,7 @@ func labeledEditor(th *material.Theme, label string, editor *widget.Editor, sens return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return outlinedField(gtx, func(gtx layout.Context) layout.Dimensions { + return outlinedFieldState(gtx, focused, func(gtx layout.Context) layout.Dimensions { mask := editor.Mask if sensitive { editor.Mask = '•' diff --git a/ui_keyboard.go b/ui_keyboard.go new file mode 100644 index 0000000..10ee114 --- /dev/null +++ b/ui_keyboard.go @@ -0,0 +1,361 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + + "gioui.org/io/key" + "git.julianfamily.org/keepassgo/appstate" +) + +type focusID string + +type detailField string + +const ( + focusSearch focusID = "search" + + detailFieldID detailField = "id" + detailFieldTitle detailField = "title" + detailFieldUsername detailField = "username" + detailFieldPassword detailField = "password" + detailFieldURL detailField = "url" + detailFieldPath detailField = "path" + detailFieldTags detailField = "tags" + detailFieldPasswordProfile detailField = "password-profile" + detailFieldNotes detailField = "notes" + detailFieldFields detailField = "fields" + detailFieldHistoryIndex detailField = "history-index" +) + +func breadcrumbFocusID(index int) focusID { + return focusID(fmt.Sprintf("breadcrumb:%d", index)) +} + +func listFocusID(index int) focusID { + return focusID(fmt.Sprintf("list:%d", index)) +} + +func detailFocusID(field detailField) focusID { + return focusID("detail:" + string(field)) +} + +func (u *ui) handleKeyPress(name key.Name, modifiers key.Modifiers) bool { + if u.handleShortcutKey(name, modifiers) { + return true + } + + switch name { + case key.NameTab: + delta := 1 + if modifiers.Contain(key.ModShift) { + delta = -1 + } + u.moveKeyboardFocus(delta) + return true + case key.NameLeftArrow, key.NameRightArrow, key.NameUpArrow, key.NameDownArrow, key.NameReturn: + return u.handleFocusedKey(name) + default: + return false + } +} + +func (u *ui) moveKeyboardFocus(delta int) { + order := u.focusOrder() + if len(order) == 0 { + return + } + + current := canonicalFocusID(u.keyboardFocus) + index := 0 + for i, item := range order { + if canonicalFocusID(item) == current { + index = i + break + } + } + + index += delta + if index < 0 { + index = len(order) - 1 + } + if index >= len(order) { + index = 0 + } + u.setKeyboardFocus(order[index]) +} + +func (u *ui) focusOrder() []focusID { + order := []focusID{focusSearch} + if u.state.Section != appstate.SectionRecycleBin { + order = append(order, breadcrumbFocusID(0)) + } + if len(u.visible) > 0 { + order = append(order, listFocusID(u.focusedListIndexOrZero())) + } + order = append(order, detailFocusID(u.focusedDetailFieldOrDefault())) + return order +} + +func (u *ui) setKeyboardFocus(id focusID) { + u.keyboardFocus = id + if strings.HasPrefix(string(id), "list:") { + u.focusListIndex(focusIndex(id)) + } +} + +func (u *ui) handleFocusedKey(name key.Name) bool { + switch { + case u.keyboardFocus == focusSearch: + if name == key.NameDownArrow && len(u.visible) > 0 { + u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero())) + return true + } + case strings.HasPrefix(string(u.keyboardFocus), "breadcrumb:"): + return u.handleBreadcrumbKey(name) + case strings.HasPrefix(string(u.keyboardFocus), "list:"): + return u.handleListKey(name) + case strings.HasPrefix(string(u.keyboardFocus), "detail:"): + return u.handleDetailKey(name) + } + return false +} + +func (u *ui) handleBreadcrumbKey(name key.Name) bool { + crumbs := u.breadcrumbLabels() + if len(crumbs) == 0 { + return false + } + + index := focusIndex(u.keyboardFocus) + switch name { + case key.NameLeftArrow: + if index > 0 { + u.keyboardFocus = breadcrumbFocusID(index - 1) + } + return true + case key.NameRightArrow: + if index < len(crumbs)-1 { + u.keyboardFocus = breadcrumbFocusID(index + 1) + } + return true + case key.NameDownArrow: + if len(u.visible) > 0 { + u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero())) + } + return true + case key.NameReturn: + u.activateBreadcrumb(index) + return true + default: + return false + } +} + +func (u *ui) handleListKey(name key.Name) bool { + if len(u.visible) == 0 { + return false + } + + index := focusIndex(u.keyboardFocus) + switch name { + case key.NameUpArrow: + if index > 0 { + u.setKeyboardFocus(listFocusID(index - 1)) + } + return true + case key.NameDownArrow: + if index < len(u.visible)-1 { + u.setKeyboardFocus(listFocusID(index + 1)) + } + return true + case key.NameLeftArrow: + u.keyboardFocus = breadcrumbFocusID(len(u.breadcrumbLabels()) - 1) + return true + case key.NameRightArrow, key.NameReturn: + u.keyboardFocus = detailFocusID(u.focusedDetailFieldOrDefault()) + return true + default: + return false + } +} + +func (u *ui) handleDetailKey(name key.Name) bool { + fields := detailFocusOrder() + index := u.focusedDetailIndex() + + switch name { + case key.NameUpArrow: + if index > 0 { + u.keyboardFocus = detailFocusID(fields[index-1]) + } + return true + case key.NameDownArrow: + if index < len(fields)-1 { + u.keyboardFocus = detailFocusID(fields[index+1]) + } + return true + case key.NameLeftArrow: + if len(u.visible) > 0 { + u.setKeyboardFocus(listFocusID(u.focusedListIndexOrZero())) + } + return true + default: + return false + } +} + +func (u *ui) handleShortcutKey(name key.Name, modifiers key.Modifiers) bool { + if !modifiers.Contain(key.ModShortcut) { + return false + } + + switch name { + case "F": + _ = u.performShortcut(shortcutSearch) + case "S": + _ = u.performShortcut(shortcutSave) + case "L": + _ = u.performShortcut(shortcutLock) + case "N": + _ = u.performShortcut(shortcutNewEntry) + case "U": + _ = u.performShortcut(shortcutCopyUser) + case "P": + _ = u.performShortcut(shortcutCopyPassword) + case "O": + _ = u.performShortcut(shortcutCopyURL) + default: + return false + } + + return true +} + +func (u *ui) activateBreadcrumb(index int) { + if index <= 0 { + u.currentPath = nil + } else { + crumbs := u.breadcrumbLabels() + u.currentPath = append([]string{}, crumbs[1:index+1]...) + } + u.filter() + if index >= len(u.breadcrumbLabels()) { + index = len(u.breadcrumbLabels()) - 1 + } + if index < 0 { + index = 0 + } + u.keyboardFocus = breadcrumbFocusID(index) +} + +func (u *ui) breadcrumbLabels() []string { + if u.state.Section == appstate.SectionRecycleBin { + return nil + } + + labels := append([]string{"Vault"}, u.currentPath...) + if u.state.Section == appstate.SectionTemplates { + labels = append([]string{"Templates"}, u.currentPath...) + } + return labels +} + +func (u *ui) focusListIndex(index int) { + if len(u.visible) == 0 { + return + } + if index < 0 { + index = 0 + } + if index >= len(u.visible) { + index = len(u.visible) - 1 + } + + u.keyboardFocus = listFocusID(index) + u.state.SelectedEntryID = u.visible[index].ID + u.loadSelectedEntryIntoEditor() +} + +func (u *ui) focusedListIndexOrZero() int { + if strings.HasPrefix(string(u.keyboardFocus), "list:") { + index := focusIndex(u.keyboardFocus) + if index >= 0 && index < len(u.visible) { + return index + } + } + + for i, item := range u.visible { + if item.ID == u.state.SelectedEntryID { + return i + } + } + + return 0 +} + +func (u *ui) focusedDetailFieldOrDefault() detailField { + if strings.HasPrefix(string(u.keyboardFocus), "detail:") { + name := strings.TrimPrefix(string(u.keyboardFocus), "detail:") + for _, field := range detailFocusOrder() { + if string(field) == name { + return field + } + } + } + + return detailFieldTitle +} + +func (u *ui) focusedDetailIndex() int { + current := u.focusedDetailFieldOrDefault() + for i, field := range detailFocusOrder() { + if field == current { + return i + } + } + return 0 +} + +func detailFocusOrder() []detailField { + return []detailField{ + detailFieldID, + detailFieldTitle, + detailFieldUsername, + detailFieldPassword, + detailFieldURL, + detailFieldPath, + detailFieldTags, + detailFieldPasswordProfile, + detailFieldNotes, + detailFieldFields, + detailFieldHistoryIndex, + } +} + +func canonicalFocusID(id focusID) focusID { + switch { + case strings.HasPrefix(string(id), "breadcrumb:"): + return breadcrumbFocusID(0) + case strings.HasPrefix(string(id), "list:"): + return listFocusID(0) + case strings.HasPrefix(string(id), "detail:"): + return detailFocusID(detailFieldTitle) + default: + return id + } +} + +func focusIndex(id focusID) int { + _, value, ok := strings.Cut(string(id), ":") + if !ok { + return 0 + } + + index, err := strconv.Atoi(value) + if err != nil { + return 0 + } + return index +} diff --git a/ui_shortcuts.go b/ui_shortcuts.go index b3ee142..711e578 100644 --- a/ui_shortcuts.go +++ b/ui_shortcuts.go @@ -24,13 +24,19 @@ func (u *ui) processShortcuts(gtx layout.Context) { event.Op(gtx.Ops, u) for { ev, ok := gtx.Event( - key.Filter{Focus: u, Name: "F", Required: key.ModShortcut}, - key.Filter{Focus: u, Name: "S", Required: key.ModShortcut}, - key.Filter{Focus: u, Name: "L", Required: key.ModShortcut}, - key.Filter{Focus: u, Name: "N", Required: key.ModShortcut}, - key.Filter{Focus: u, Name: "U", Required: key.ModShortcut}, - key.Filter{Focus: u, Name: "P", Required: key.ModShortcut}, - key.Filter{Focus: u, Name: "O", Required: key.ModShortcut}, + key.Filter{Name: "F", Required: key.ModShortcut}, + key.Filter{Name: "S", Required: key.ModShortcut}, + key.Filter{Name: "L", Required: key.ModShortcut}, + key.Filter{Name: "N", Required: key.ModShortcut}, + key.Filter{Name: "U", Required: key.ModShortcut}, + key.Filter{Name: "P", Required: key.ModShortcut}, + key.Filter{Name: "O", Required: key.ModShortcut}, + key.Filter{Name: key.NameTab, Optional: key.ModShift}, + key.Filter{Name: key.NameLeftArrow}, + key.Filter{Name: key.NameRightArrow}, + key.Filter{Name: key.NameUpArrow}, + key.Filter{Name: key.NameDownArrow}, + key.Filter{Name: key.NameReturn}, ) if !ok { break @@ -41,28 +47,14 @@ func (u *ui) processShortcuts(gtx layout.Context) { continue } - switch ke.Name { - case "F": - _ = u.performShortcut(shortcutSearch) - case "S": - _ = u.performShortcut(shortcutSave) - case "L": - _ = u.performShortcut(shortcutLock) - case "N": - _ = u.performShortcut(shortcutNewEntry) - case "U": - _ = u.performShortcut(shortcutCopyUser) - case "P": - _ = u.performShortcut(shortcutCopyPassword) - case "O": - _ = u.performShortcut(shortcutCopyURL) - } + u.handleKeyPress(ke.Name, ke.Modifiers) } } func (u *ui) performShortcut(name string) error { switch name { case shortcutSearch: + u.keyboardFocus = focusSearch return nil case shortcutSave: return u.saveAction() @@ -72,6 +64,7 @@ func (u *ui) performShortcut(name string) error { u.state.SelectedEntryID = "" u.loadSelectedEntryIntoEditor() u.entryPath.SetText(strings.Join(u.currentPath, " / ")) + u.keyboardFocus = detailFocusID(detailFieldTitle) return nil case shortcutCopyUser: return u.copySelectedFieldAction(clipboard.TargetUsername) From 6c1ccdad16ccc595be94044413f4496756d006d1 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:21:02 -0700 Subject: [PATCH 08/13] Complete search section behavior --- appstate/state.go | 13 ++++ appstate/state_test.go | 136 +++++++++++++++++++++++++++++++++++++++++ main.go | 10 ++- main_test.go | 82 +++++++++++++++++++++++++ 4 files changed, 240 insertions(+), 1 deletion(-) diff --git a/appstate/state.go b/appstate/state.go index cd15381..e963534 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -161,6 +161,19 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry { } } +func (s State) SearchPathContext(entry vault.Entry) string { + path := slices.Clone(entry.Path) + switch s.Section { + case SectionTemplates: + if len(path) == 0 || path[0] != "Templates" { + path = append([]string{"Templates"}, path...) + } + case SectionRecycleBin: + path = append([]string{"Recycle Bin"}, path...) + } + return strings.Join(path, " / ") +} + func entriesInPath(entries []vault.Entry, path []string) []vault.Entry { var out []vault.Entry for _, entry := range entries { diff --git a/appstate/state_test.go b/appstate/state_test.go index 5f3af72..e4b194d 100644 --- a/appstate/state_test.go +++ b/appstate/state_test.go @@ -117,6 +117,142 @@ func TestVisibleEntriesUsesRecycleBinSection(t *testing.T) { } } +func TestVisibleEntriesUsesGlobalSearchWithinTemplateSection(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + Templates: []vault.Entry{ + {ID: "tpl-1", Title: "Website Login", URL: "https://accounts.example.com", Path: []string{"Templates", "Web"}}, + {ID: "tpl-2", Title: "SSH Login", URL: "ssh://infra.internal", Path: []string{"Templates", "Infra"}}, + }, + }, + }, + Section: SectionTemplates, + CurrentPath: []string{"Templates", "Web"}, + SearchQuery: "infra", + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() error = %v", err) + } + + if len(got) != 1 || got[0].ID != "tpl-2" { + t.Fatalf("VisibleEntries() = %#v, want global template search result tpl-2", got) + } +} + +func TestVisibleEntriesResetToCurrentTemplatePathAfterClearingSearch(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + Templates: []vault.Entry{ + {ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}}, + {ID: "tpl-2", Title: "Email Login", Path: []string{"Templates", "Web"}}, + {ID: "tpl-3", Title: "SSH Login", Path: []string{"Templates", "Infra"}}, + }, + }, + }, + Section: SectionTemplates, + CurrentPath: []string{"Templates", "Web"}, + SearchQuery: "ssh", + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() with search error = %v", err) + } + if len(got) != 1 || got[0].ID != "tpl-3" { + t.Fatalf("VisibleEntries() with search = %#v, want tpl-3", got) + } + + state.SearchQuery = "" + got, err = state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() after clearing search error = %v", err) + } + + if len(got) != 2 { + t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 2", len(got)) + } + if titles := []string{got[0].Title, got[1].Title}; !slices.Equal(titles, []string{"Email Login", "Website Login"}) { + t.Fatalf("VisibleEntries() after clearing search titles = %v, want [Email Login Website Login]", titles) + } +} + +func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + RecycleBin: []vault.Entry{ + {ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}}, + {ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}}, + }, + }, + }, + Section: SectionRecycleBin, + CurrentPath: []string{"Root", "Internet"}, + SearchQuery: "climate", + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() error = %v", err) + } + + if len(got) != 1 || got[0].ID != "deleted-2" { + t.Fatalf("VisibleEntries() = %#v, want global recycle-bin search result deleted-2", got) + } +} + +func TestSearchPathContextIncludesSectionRoots(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + section Section + entry vault.Entry + want string + }{ + { + name: "entries use direct path", + section: SectionEntries, + entry: vault.Entry{Path: []string{"Root", "Internet"}}, + want: "Root / Internet", + }, + { + name: "templates retain templates root", + section: SectionTemplates, + entry: vault.Entry{Path: []string{"Templates", "Web"}}, + want: "Templates / Web", + }, + { + name: "recycle bin prefixes root label", + section: SectionRecycleBin, + entry: vault.Entry{Path: []string{"Root", "Internet"}}, + want: "Recycle Bin / Root / Internet", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + state := State{Section: tt.section} + if got := state.SearchPathContext(tt.entry); got != tt.want { + t.Fatalf("SearchPathContext(%v) = %q, want %q", tt.entry.Path, got, tt.want) + } + }) + } +} + func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) { t.Parallel() diff --git a/main.go b/main.go index 056a420..a4abfe6 100644 --- a/main.go +++ b/main.go @@ -269,6 +269,14 @@ func (u *ui) filteredTitles() []string { return titles } +func (u *ui) visiblePathContexts() []string { + paths := make([]string, 0, len(u.visible)) + for _, item := range u.visible { + paths = append(paths, u.state.SearchPathContext(item)) + } + return paths +} + func (u *ui) selectedEntry() (entry, bool) { for _, item := range u.visible { if item.ID == u.state.SelectedEntryID { @@ -862,7 +870,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item if strings.TrimSpace(u.search.Text()) == "" { return layout.Dimensions{} } - lbl := material.Label(u.theme, unit.Sp(11), strings.Join(item.Path, " / ")) + lbl := material.Label(u.theme, unit.Sp(11), u.state.SearchPathContext(item)) lbl.Color = mutedColor return lbl.Layout(gtx) }), diff --git a/main_test.go b/main_test.go index d0e4136..b97ce50 100644 --- a/main_test.go +++ b/main_test.go @@ -43,6 +43,88 @@ func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) { } } +func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T) { + t.Parallel() + + modes := []string{"desktop", "phone"} + for _, mode := range modes { + mode := mode + t.Run(mode, func(t *testing.T) { + t.Parallel() + + u := newUIWithModel(mode, vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}}, + {ID: "entry-2", Title: "HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}}, + }, + Templates: []vault.Entry{ + {ID: "tpl-1", Title: "Website Login", URL: "https://accounts.example.com", Path: []string{"Templates", "Web"}}, + {ID: "tpl-2", Title: "SSH Login", URL: "ssh://infra.internal", Path: []string{"Templates", "Infra"}}, + }, + RecycleBin: []vault.Entry{ + {ID: "deleted-1", Title: "Deleted Bellagio", URL: "https://bellagio.example.com", Path: []string{"Root", "Internet"}}, + {ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}}, + }, + }) + + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.search.SetText("climate") + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"HVAC"}) { + t.Fatalf("entries filteredTitles() = %v, want [HVAC]", got) + } + + u.showTemplatesSection() + u.currentPath = []string{"Templates", "Web"} + u.search.SetText("infra") + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"SSH Login"}) { + t.Fatalf("templates filteredTitles() = %v, want [SSH Login]", got) + } + if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Templates / Infra"}) { + t.Fatalf("templates visiblePathContexts() = %v, want [Templates / Infra]", got) + } + + u.showRecycleBinSection() + u.search.SetText("climate") + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Deleted HVAC"}) { + t.Fatalf("recycle filteredTitles() = %v, want [Deleted HVAC]", got) + } + if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Recycle Bin / Root / Home"}) { + t.Fatalf("recycle visiblePathContexts() = %v, want [Recycle Bin / Root / Home]", got) + } + }) + } +} + +func TestUIClearingSearchResetsToCurrentSectionListing(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Templates: []vault.Entry{ + {ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}}, + {ID: "tpl-2", Title: "Email Login", Path: []string{"Templates", "Web"}}, + {ID: "tpl-3", Title: "SSH Login", Path: []string{"Templates", "Infra"}}, + }, + }) + + u.showTemplatesSection() + u.currentPath = []string{"Templates", "Web"} + u.search.SetText("ssh") + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"SSH Login"}) { + t.Fatalf("filteredTitles() with search = %v, want [SSH Login]", got) + } + + u.search.SetText("") + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Email Login", "Website Login"}) { + t.Fatalf("filteredTitles() after clearing search = %v, want [Email Login Website Login]", got) + } +} + func TestUIChildGroupsComeFromVaultModel(t *testing.T) { t.Parallel() From 5a6ba8ff571a316540939e97cd559070b7bc2d91 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:21:41 -0700 Subject: [PATCH 09/13] Add gRPC group deletion and custom entry fields --- api/server.go | 25 ++ api/server_test.go | 84 +++- proto/keepassgo/v1/keepassgo.pb.go | 486 ++++++++++++++---------- proto/keepassgo/v1/keepassgo.proto | 8 + proto/keepassgo/v1/keepassgo_grpc.pb.go | 38 ++ vault/model.go | 25 +- 6 files changed, 456 insertions(+), 210 deletions(-) diff --git a/api/server.go b/api/server.go index 6aa6b34..a9352d8 100644 --- a/api/server.go +++ b/api/server.go @@ -264,6 +264,29 @@ func (s *Server) RenameGroup(_ context.Context, req *keepassgov1.RenameGroupRequ return &keepassgov1.RenameGroupResponse{}, nil } +func (s *Server) DeleteGroup(_ context.Context, req *keepassgov1.DeleteGroupRequest) (*keepassgov1.DeleteGroupResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.locked { + return nil, status.Error(codes.FailedPrecondition, "vault is locked") + } + + if err := s.model.DeleteGroup(req.GetPath()); err != nil { + switch { + case errors.Is(err, vault.ErrEntryNotFound): + return nil, status.Error(codes.NotFound, err.Error()) + case errors.Is(err, vault.ErrGroupNotEmpty): + return nil, status.Error(codes.FailedPrecondition, err.Error()) + default: + return nil, status.Errorf(codes.Internal, "delete group: %v", err) + } + } + + s.dirty = true + return &keepassgov1.DeleteGroupResponse{}, nil +} + func (s *Server) UpsertEntry(_ context.Context, req *keepassgov1.UpsertEntryRequest) (*keepassgov1.UpsertEntryResponse, error) { if req.GetEntry() == nil { return nil, status.Error(codes.InvalidArgument, "missing entry") @@ -605,6 +628,7 @@ func entryToProto(entry vault.Entry) *keepassgov1.Entry { Notes: entry.Notes, Tags: append([]string(nil), entry.Tags...), Path: append([]string(nil), entry.Path...), + Fields: maps.Clone(entry.Fields), } } @@ -618,6 +642,7 @@ func entryFromProto(entry *keepassgov1.Entry) vault.Entry { Notes: entry.GetNotes(), Tags: append([]string(nil), entry.GetTags()...), Path: append([]string(nil), entry.GetPath()...), + Fields: maps.Clone(entry.GetFields()), } } diff --git a/api/server_test.go b/api/server_test.go index 02500fb..7cca1fd 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -378,6 +378,9 @@ func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) { if resp.Entries[0].Title != "Vault Console" { t.Fatalf("ListEntries().Entries[0].Title = %q, want %q", resp.Entries[0].Title, "Vault Console") } + if got := resp.Entries[0].Fields["X-Role"]; got != "automation" { + t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "automation") + } } func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing.T) { @@ -427,6 +430,42 @@ func TestVaultServiceListsCreatesAndRenamesGroupsForAuthorizedClients(t *testing } } +func TestVaultServiceDeletesEmptyGroupsAndRejectsNonEmptyGroups(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClient(t) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + if _, err := client.CreateGroup(ctx, &keepassgov1.CreateGroupRequest{ + ParentPath: []string{"Root"}, + Name: "Finance", + }); err != nil { + t.Fatalf("CreateGroup() error = %v", err) + } + + if _, err := client.DeleteGroup(ctx, &keepassgov1.DeleteGroupRequest{ + Path: []string{"Root", "Finance"}, + }); err != nil { + t.Fatalf("DeleteGroup() error = %v, want success for empty group", err) + } + + listed, err := client.ListGroups(ctx, &keepassgov1.ListGroupsRequest{Path: []string{"Root"}}) + if err != nil { + t.Fatalf("ListGroups() error = %v", err) + } + if len(listed.Names) != 2 || listed.Names[0] != "Home Assistant" || listed.Names[1] != "Internet" { + t.Fatalf("ListGroups().Names = %#v, want empty Finance group removed", listed.Names) + } + + _, err = client.DeleteGroup(ctx, &keepassgov1.DeleteGroupRequest{ + Path: []string{"Root", "Internet"}, + }) + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("DeleteGroup() code = %v, want %v for non-empty group", status.Code(err), codes.FailedPrecondition) + } +} + func TestVaultServiceGeneratesPasswordsForAuthorizedClients(t *testing.T) { t.Parallel() @@ -477,7 +516,10 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) { Username: "codex", Password: "token-2", Url: "https://surveillance.crew.example.invalid", - Path: []string{"Root", "Home Assistant"}, + Fields: map[string]string{ + "X-Role": "lights-admin", + }, + Path: []string{"Root", "Home Assistant"}, }, }) if err != nil { @@ -487,6 +529,9 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) { if upserted.Entry.Title != "Surveillance Console" { t.Fatalf("UpsertEntry().Entry.Title = %q, want %q", upserted.Entry.Title, "Surveillance Console") } + if got := upserted.Entry.Fields["X-Role"]; got != "lights-admin" { + t.Fatalf("UpsertEntry().Entry.Fields[X-Role] = %q, want %q", got, "lights-admin") + } listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Home Assistant"}}) if err != nil { @@ -496,6 +541,9 @@ func TestVaultServiceUpsertsEntriesForAuthorizedClients(t *testing.T) { if len(listed.Entries) != 1 || listed.Entries[0].Password != "token-2" { t.Fatalf("ListEntries().Entries = %#v, want persisted Home Assistant entry", listed.Entries) } + if got := listed.Entries[0].Fields["X-Role"]; got != "lights-admin" { + t.Fatalf("ListEntries().Entries[0].Fields[X-Role] = %q, want %q", got, "lights-admin") + } } func TestVaultServiceDeletesAndRestoresEntriesForAuthorizedClients(t *testing.T) { @@ -552,6 +600,9 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin if len(templates.Templates) != 1 || templates.Templates[0].Title != "Website Login" { t.Fatalf("ListTemplates().Templates = %#v, want Website Login template", templates.Templates) } + if got := templates.Templates[0].Fields["Environment"]; got != "prod" { + t.Fatalf("ListTemplates().Templates[0].Fields[Environment] = %q, want %q", got, "prod") + } instantiated, err := client.InstantiateTemplate(ctx, &keepassgov1.InstantiateTemplateRequest{ TemplateId: "website-login", @@ -561,8 +612,11 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin Username: "rustyryan", Password: "hunter2", Url: "https://bellagio.example.invalid", - Path: []string{"Root", "Internet"}, - Tags: []string{"dns"}, + Fields: map[string]string{ + "Environment": "staging", + }, + Path: []string{"Root", "Internet"}, + Tags: []string{"dns"}, }, }) if err != nil { @@ -572,6 +626,9 @@ func TestVaultServiceListsAndInstantiatesTemplatesForAuthorizedClients(t *testin if instantiated.Entry.Title != "Bellagio" || instantiated.Entry.Notes != "Reusable template for website accounts." { t.Fatalf("InstantiateTemplate().Entry = %#v, want Bellagio entry with template notes", instantiated.Entry) } + if got := instantiated.Entry.Fields["Environment"]; got != "staging" { + t.Fatalf("InstantiateTemplate().Entry.Fields[Environment] = %q, want %q", got, "staging") + } listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) if err != nil { @@ -596,7 +653,10 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T Title: "Website Login Updated", Username: "template-user", Password: "template-password", - Path: []string{"Templates", "Web"}, + Fields: map[string]string{ + "Environment": "dev", + }, + Path: []string{"Templates", "Web"}, }, }) if err != nil { @@ -605,6 +665,9 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T if upserted.Template.Title != "Website Login Updated" { t.Fatalf("UpsertTemplate().Template.Title = %q, want updated title", upserted.Template.Title) } + if got := upserted.Template.Fields["Environment"]; got != "dev" { + t.Fatalf("UpsertTemplate().Template.Fields[Environment] = %q, want %q", got, "dev") + } listed, err := client.ListTemplates(ctx, &keepassgov1.ListTemplatesRequest{}) if err != nil { @@ -613,6 +676,9 @@ func TestVaultServiceUpsertsAndDeletesTemplatesForAuthorizedClients(t *testing.T if len(listed.Templates) != 1 || listed.Templates[0].Title != "Website Login Updated" { t.Fatalf("ListTemplates().Templates = %#v, want updated template", listed.Templates) } + if got := listed.Templates[0].Fields["Environment"]; got != "dev" { + t.Fatalf("ListTemplates().Templates[0].Fields[Environment] = %q, want %q", got, "dev") + } if _, err := client.DeleteTemplate(ctx, &keepassgov1.DeleteTemplateRequest{Id: "website-login"}); err != nil { t.Fatalf("DeleteTemplate() error = %v", err) @@ -721,6 +787,9 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa Username: "dannyocean", Password: "token-1", URL: "https://vault.crew.example.invalid", + Fields: map[string]string{ + "X-Role": "automation", + }, History: []vault.Entry{ { ID: "vault-console-h1", @@ -750,8 +819,11 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa Password: "template-password", URL: "https://example.com", Notes: "Reusable template for website accounts.", - Tags: []string{"template", "web"}, - Path: []string{"Templates"}, + Fields: map[string]string{ + "Environment": "prod", + }, + Tags: []string{"template", "web"}, + Path: []string{"Templates"}, }, }, }, diff --git a/proto/keepassgo/v1/keepassgo.pb.go b/proto/keepassgo/v1/keepassgo.pb.go index 8708e19..92e07f2 100644 --- a/proto/keepassgo/v1/keepassgo.pb.go +++ b/proto/keepassgo/v1/keepassgo.pb.go @@ -627,6 +627,7 @@ type Entry struct { Notes string `protobuf:"bytes,6,opt,name=notes,proto3" json:"notes,omitempty"` Tags []string `protobuf:"bytes,7,rep,name=tags,proto3" json:"tags,omitempty"` Path []string `protobuf:"bytes,8,rep,name=path,proto3" json:"path,omitempty"` + Fields map[string]string `protobuf:"bytes,9,rep,name=fields,proto3" json:"fields,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -717,6 +718,13 @@ func (x *Entry) GetPath() []string { return nil } +func (x *Entry) GetFields() map[string]string { + if x != nil { + return x.Fields + } + return nil +} + type ListEntriesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Entries []*Entry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` @@ -1025,6 +1033,86 @@ func (*RenameGroupResponse) Descriptor() ([]byte, []int) { return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{20} } +type DeleteGroupRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path []string `protobuf:"bytes,1,rep,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteGroupRequest) Reset() { + *x = DeleteGroupRequest{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteGroupRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteGroupRequest) ProtoMessage() {} + +func (x *DeleteGroupRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteGroupRequest.ProtoReflect.Descriptor instead. +func (*DeleteGroupRequest) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{21} +} + +func (x *DeleteGroupRequest) GetPath() []string { + if x != nil { + return x.Path + } + return nil +} + +type DeleteGroupResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteGroupResponse) Reset() { + *x = DeleteGroupResponse{} + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteGroupResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteGroupResponse) ProtoMessage() {} + +func (x *DeleteGroupResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteGroupResponse.ProtoReflect.Descriptor instead. +func (*DeleteGroupResponse) Descriptor() ([]byte, []int) { + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{22} +} + type UpsertEntryRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Entry *Entry `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` @@ -1034,7 +1122,7 @@ type UpsertEntryRequest struct { func (x *UpsertEntryRequest) Reset() { *x = UpsertEntryRequest{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[21] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1046,7 +1134,7 @@ func (x *UpsertEntryRequest) String() string { func (*UpsertEntryRequest) ProtoMessage() {} func (x *UpsertEntryRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[21] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1059,7 +1147,7 @@ func (x *UpsertEntryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpsertEntryRequest.ProtoReflect.Descriptor instead. func (*UpsertEntryRequest) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{21} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{23} } func (x *UpsertEntryRequest) GetEntry() *Entry { @@ -1078,7 +1166,7 @@ type UpsertEntryResponse struct { func (x *UpsertEntryResponse) Reset() { *x = UpsertEntryResponse{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[22] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1090,7 +1178,7 @@ func (x *UpsertEntryResponse) String() string { func (*UpsertEntryResponse) ProtoMessage() {} func (x *UpsertEntryResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[22] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1103,7 +1191,7 @@ func (x *UpsertEntryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpsertEntryResponse.ProtoReflect.Descriptor instead. func (*UpsertEntryResponse) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{22} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{24} } func (x *UpsertEntryResponse) GetEntry() *Entry { @@ -1122,7 +1210,7 @@ type DeleteEntryRequest struct { func (x *DeleteEntryRequest) Reset() { *x = DeleteEntryRequest{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[23] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1134,7 +1222,7 @@ func (x *DeleteEntryRequest) String() string { func (*DeleteEntryRequest) ProtoMessage() {} func (x *DeleteEntryRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[23] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1147,7 +1235,7 @@ func (x *DeleteEntryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteEntryRequest.ProtoReflect.Descriptor instead. func (*DeleteEntryRequest) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{23} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{25} } func (x *DeleteEntryRequest) GetId() string { @@ -1165,7 +1253,7 @@ type DeleteEntryResponse struct { func (x *DeleteEntryResponse) Reset() { *x = DeleteEntryResponse{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[24] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1177,7 +1265,7 @@ func (x *DeleteEntryResponse) String() string { func (*DeleteEntryResponse) ProtoMessage() {} func (x *DeleteEntryResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[24] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1190,7 +1278,7 @@ func (x *DeleteEntryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteEntryResponse.ProtoReflect.Descriptor instead. func (*DeleteEntryResponse) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{24} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{26} } type RestoreEntryRequest struct { @@ -1202,7 +1290,7 @@ type RestoreEntryRequest struct { func (x *RestoreEntryRequest) Reset() { *x = RestoreEntryRequest{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[25] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1214,7 +1302,7 @@ func (x *RestoreEntryRequest) String() string { func (*RestoreEntryRequest) ProtoMessage() {} func (x *RestoreEntryRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[25] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1227,7 +1315,7 @@ func (x *RestoreEntryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreEntryRequest.ProtoReflect.Descriptor instead. func (*RestoreEntryRequest) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{25} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{27} } func (x *RestoreEntryRequest) GetId() string { @@ -1246,7 +1334,7 @@ type RestoreEntryResponse struct { func (x *RestoreEntryResponse) Reset() { *x = RestoreEntryResponse{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[26] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1258,7 +1346,7 @@ func (x *RestoreEntryResponse) String() string { func (*RestoreEntryResponse) ProtoMessage() {} func (x *RestoreEntryResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[26] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1271,7 +1359,7 @@ func (x *RestoreEntryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreEntryResponse.ProtoReflect.Descriptor instead. func (*RestoreEntryResponse) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{26} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{28} } func (x *RestoreEntryResponse) GetEntry() *Entry { @@ -1290,7 +1378,7 @@ type ListEntryHistoryRequest struct { func (x *ListEntryHistoryRequest) Reset() { *x = ListEntryHistoryRequest{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[27] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1302,7 +1390,7 @@ func (x *ListEntryHistoryRequest) String() string { func (*ListEntryHistoryRequest) ProtoMessage() {} func (x *ListEntryHistoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[27] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1315,7 +1403,7 @@ func (x *ListEntryHistoryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListEntryHistoryRequest.ProtoReflect.Descriptor instead. func (*ListEntryHistoryRequest) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{27} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{29} } func (x *ListEntryHistoryRequest) GetId() string { @@ -1334,7 +1422,7 @@ type ListEntryHistoryResponse struct { func (x *ListEntryHistoryResponse) Reset() { *x = ListEntryHistoryResponse{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[28] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1346,7 +1434,7 @@ func (x *ListEntryHistoryResponse) String() string { func (*ListEntryHistoryResponse) ProtoMessage() {} func (x *ListEntryHistoryResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[28] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1359,7 +1447,7 @@ func (x *ListEntryHistoryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListEntryHistoryResponse.ProtoReflect.Descriptor instead. func (*ListEntryHistoryResponse) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{28} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{30} } func (x *ListEntryHistoryResponse) GetEntries() []*Entry { @@ -1379,7 +1467,7 @@ type RestoreEntryHistoryRequest struct { func (x *RestoreEntryHistoryRequest) Reset() { *x = RestoreEntryHistoryRequest{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[29] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1391,7 +1479,7 @@ func (x *RestoreEntryHistoryRequest) String() string { func (*RestoreEntryHistoryRequest) ProtoMessage() {} func (x *RestoreEntryHistoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[29] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1404,7 +1492,7 @@ func (x *RestoreEntryHistoryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreEntryHistoryRequest.ProtoReflect.Descriptor instead. func (*RestoreEntryHistoryRequest) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{29} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{31} } func (x *RestoreEntryHistoryRequest) GetId() string { @@ -1430,7 +1518,7 @@ type RestoreEntryHistoryResponse struct { func (x *RestoreEntryHistoryResponse) Reset() { *x = RestoreEntryHistoryResponse{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[30] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1442,7 +1530,7 @@ func (x *RestoreEntryHistoryResponse) String() string { func (*RestoreEntryHistoryResponse) ProtoMessage() {} func (x *RestoreEntryHistoryResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[30] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1455,7 +1543,7 @@ func (x *RestoreEntryHistoryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RestoreEntryHistoryResponse.ProtoReflect.Descriptor instead. func (*RestoreEntryHistoryResponse) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{30} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{32} } func (x *RestoreEntryHistoryResponse) GetEntry() *Entry { @@ -1473,7 +1561,7 @@ type ListTemplatesRequest struct { func (x *ListTemplatesRequest) Reset() { *x = ListTemplatesRequest{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[31] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1485,7 +1573,7 @@ func (x *ListTemplatesRequest) String() string { func (*ListTemplatesRequest) ProtoMessage() {} func (x *ListTemplatesRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[31] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1498,7 +1586,7 @@ func (x *ListTemplatesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListTemplatesRequest.ProtoReflect.Descriptor instead. func (*ListTemplatesRequest) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{31} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{33} } type ListTemplatesResponse struct { @@ -1510,7 +1598,7 @@ type ListTemplatesResponse struct { func (x *ListTemplatesResponse) Reset() { *x = ListTemplatesResponse{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[32] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1522,7 +1610,7 @@ func (x *ListTemplatesResponse) String() string { func (*ListTemplatesResponse) ProtoMessage() {} func (x *ListTemplatesResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[32] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1535,7 +1623,7 @@ func (x *ListTemplatesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListTemplatesResponse.ProtoReflect.Descriptor instead. func (*ListTemplatesResponse) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{32} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{34} } func (x *ListTemplatesResponse) GetTemplates() []*Entry { @@ -1554,7 +1642,7 @@ type UpsertTemplateRequest struct { func (x *UpsertTemplateRequest) Reset() { *x = UpsertTemplateRequest{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[33] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1566,7 +1654,7 @@ func (x *UpsertTemplateRequest) String() string { func (*UpsertTemplateRequest) ProtoMessage() {} func (x *UpsertTemplateRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[33] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1579,7 +1667,7 @@ func (x *UpsertTemplateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpsertTemplateRequest.ProtoReflect.Descriptor instead. func (*UpsertTemplateRequest) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{33} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{35} } func (x *UpsertTemplateRequest) GetTemplate() *Entry { @@ -1598,7 +1686,7 @@ type UpsertTemplateResponse struct { func (x *UpsertTemplateResponse) Reset() { *x = UpsertTemplateResponse{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[34] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1610,7 +1698,7 @@ func (x *UpsertTemplateResponse) String() string { func (*UpsertTemplateResponse) ProtoMessage() {} func (x *UpsertTemplateResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[34] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1623,7 +1711,7 @@ func (x *UpsertTemplateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpsertTemplateResponse.ProtoReflect.Descriptor instead. func (*UpsertTemplateResponse) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{34} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{36} } func (x *UpsertTemplateResponse) GetTemplate() *Entry { @@ -1642,7 +1730,7 @@ type DeleteTemplateRequest struct { func (x *DeleteTemplateRequest) Reset() { *x = DeleteTemplateRequest{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[35] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1654,7 +1742,7 @@ func (x *DeleteTemplateRequest) String() string { func (*DeleteTemplateRequest) ProtoMessage() {} func (x *DeleteTemplateRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[35] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1667,7 +1755,7 @@ func (x *DeleteTemplateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteTemplateRequest.ProtoReflect.Descriptor instead. func (*DeleteTemplateRequest) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{35} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{37} } func (x *DeleteTemplateRequest) GetId() string { @@ -1685,7 +1773,7 @@ type DeleteTemplateResponse struct { func (x *DeleteTemplateResponse) Reset() { *x = DeleteTemplateResponse{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[36] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1697,7 +1785,7 @@ func (x *DeleteTemplateResponse) String() string { func (*DeleteTemplateResponse) ProtoMessage() {} func (x *DeleteTemplateResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[36] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1710,7 +1798,7 @@ func (x *DeleteTemplateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteTemplateResponse.ProtoReflect.Descriptor instead. func (*DeleteTemplateResponse) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{36} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{38} } type InstantiateTemplateRequest struct { @@ -1723,7 +1811,7 @@ type InstantiateTemplateRequest struct { func (x *InstantiateTemplateRequest) Reset() { *x = InstantiateTemplateRequest{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[37] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1735,7 +1823,7 @@ func (x *InstantiateTemplateRequest) String() string { func (*InstantiateTemplateRequest) ProtoMessage() {} func (x *InstantiateTemplateRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[37] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1748,7 +1836,7 @@ func (x *InstantiateTemplateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InstantiateTemplateRequest.ProtoReflect.Descriptor instead. func (*InstantiateTemplateRequest) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{37} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{39} } func (x *InstantiateTemplateRequest) GetTemplateId() string { @@ -1774,7 +1862,7 @@ type InstantiateTemplateResponse struct { func (x *InstantiateTemplateResponse) Reset() { *x = InstantiateTemplateResponse{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[38] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1786,7 +1874,7 @@ func (x *InstantiateTemplateResponse) String() string { func (*InstantiateTemplateResponse) ProtoMessage() {} func (x *InstantiateTemplateResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[38] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1799,7 +1887,7 @@ func (x *InstantiateTemplateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InstantiateTemplateResponse.ProtoReflect.Descriptor instead. func (*InstantiateTemplateResponse) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{38} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{40} } func (x *InstantiateTemplateResponse) GetEntry() *Entry { @@ -1818,7 +1906,7 @@ type ListAttachmentsRequest struct { func (x *ListAttachmentsRequest) Reset() { *x = ListAttachmentsRequest{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[39] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1830,7 +1918,7 @@ func (x *ListAttachmentsRequest) String() string { func (*ListAttachmentsRequest) ProtoMessage() {} func (x *ListAttachmentsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[39] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1843,7 +1931,7 @@ func (x *ListAttachmentsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListAttachmentsRequest.ProtoReflect.Descriptor instead. func (*ListAttachmentsRequest) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{39} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{41} } func (x *ListAttachmentsRequest) GetEntryId() string { @@ -1862,7 +1950,7 @@ type ListAttachmentsResponse struct { func (x *ListAttachmentsResponse) Reset() { *x = ListAttachmentsResponse{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[40] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1874,7 +1962,7 @@ func (x *ListAttachmentsResponse) String() string { func (*ListAttachmentsResponse) ProtoMessage() {} func (x *ListAttachmentsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[40] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1887,7 +1975,7 @@ func (x *ListAttachmentsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListAttachmentsResponse.ProtoReflect.Descriptor instead. func (*ListAttachmentsResponse) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{40} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{42} } func (x *ListAttachmentsResponse) GetNames() []string { @@ -1908,7 +1996,7 @@ type UploadAttachmentRequest struct { func (x *UploadAttachmentRequest) Reset() { *x = UploadAttachmentRequest{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[41] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1920,7 +2008,7 @@ func (x *UploadAttachmentRequest) String() string { func (*UploadAttachmentRequest) ProtoMessage() {} func (x *UploadAttachmentRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[41] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1933,7 +2021,7 @@ func (x *UploadAttachmentRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UploadAttachmentRequest.ProtoReflect.Descriptor instead. func (*UploadAttachmentRequest) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{41} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{43} } func (x *UploadAttachmentRequest) GetEntryId() string { @@ -1965,7 +2053,7 @@ type UploadAttachmentResponse struct { func (x *UploadAttachmentResponse) Reset() { *x = UploadAttachmentResponse{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[42] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1977,7 +2065,7 @@ func (x *UploadAttachmentResponse) String() string { func (*UploadAttachmentResponse) ProtoMessage() {} func (x *UploadAttachmentResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[42] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1990,7 +2078,7 @@ func (x *UploadAttachmentResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UploadAttachmentResponse.ProtoReflect.Descriptor instead. func (*UploadAttachmentResponse) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{42} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{44} } type DownloadAttachmentRequest struct { @@ -2003,7 +2091,7 @@ type DownloadAttachmentRequest struct { func (x *DownloadAttachmentRequest) Reset() { *x = DownloadAttachmentRequest{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[43] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2015,7 +2103,7 @@ func (x *DownloadAttachmentRequest) String() string { func (*DownloadAttachmentRequest) ProtoMessage() {} func (x *DownloadAttachmentRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[43] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2028,7 +2116,7 @@ func (x *DownloadAttachmentRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DownloadAttachmentRequest.ProtoReflect.Descriptor instead. func (*DownloadAttachmentRequest) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{43} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{45} } func (x *DownloadAttachmentRequest) GetEntryId() string { @@ -2054,7 +2142,7 @@ type DownloadAttachmentResponse struct { func (x *DownloadAttachmentResponse) Reset() { *x = DownloadAttachmentResponse{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[44] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2066,7 +2154,7 @@ func (x *DownloadAttachmentResponse) String() string { func (*DownloadAttachmentResponse) ProtoMessage() {} func (x *DownloadAttachmentResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[44] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2079,7 +2167,7 @@ func (x *DownloadAttachmentResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DownloadAttachmentResponse.ProtoReflect.Descriptor instead. func (*DownloadAttachmentResponse) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{44} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{46} } func (x *DownloadAttachmentResponse) GetContent() []byte { @@ -2099,7 +2187,7 @@ type DeleteAttachmentRequest struct { func (x *DeleteAttachmentRequest) Reset() { *x = DeleteAttachmentRequest{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[45] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2111,7 +2199,7 @@ func (x *DeleteAttachmentRequest) String() string { func (*DeleteAttachmentRequest) ProtoMessage() {} func (x *DeleteAttachmentRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[45] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2124,7 +2212,7 @@ func (x *DeleteAttachmentRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteAttachmentRequest.ProtoReflect.Descriptor instead. func (*DeleteAttachmentRequest) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{45} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{47} } func (x *DeleteAttachmentRequest) GetEntryId() string { @@ -2149,7 +2237,7 @@ type DeleteAttachmentResponse struct { func (x *DeleteAttachmentResponse) Reset() { *x = DeleteAttachmentResponse{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[46] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2161,7 +2249,7 @@ func (x *DeleteAttachmentResponse) String() string { func (*DeleteAttachmentResponse) ProtoMessage() {} func (x *DeleteAttachmentResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[46] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2174,7 +2262,7 @@ func (x *DeleteAttachmentResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteAttachmentResponse.ProtoReflect.Descriptor instead. func (*DeleteAttachmentResponse) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{46} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{48} } type CopyEntryFieldRequest struct { @@ -2187,7 +2275,7 @@ type CopyEntryFieldRequest struct { func (x *CopyEntryFieldRequest) Reset() { *x = CopyEntryFieldRequest{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[47] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2199,7 +2287,7 @@ func (x *CopyEntryFieldRequest) String() string { func (*CopyEntryFieldRequest) ProtoMessage() {} func (x *CopyEntryFieldRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[47] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2212,7 +2300,7 @@ func (x *CopyEntryFieldRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CopyEntryFieldRequest.ProtoReflect.Descriptor instead. func (*CopyEntryFieldRequest) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{47} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{49} } func (x *CopyEntryFieldRequest) GetId() string { @@ -2237,7 +2325,7 @@ type CopyEntryFieldResponse struct { func (x *CopyEntryFieldResponse) Reset() { *x = CopyEntryFieldResponse{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[48] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2249,7 +2337,7 @@ func (x *CopyEntryFieldResponse) String() string { func (*CopyEntryFieldResponse) ProtoMessage() {} func (x *CopyEntryFieldResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[48] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2262,7 +2350,7 @@ func (x *CopyEntryFieldResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CopyEntryFieldResponse.ProtoReflect.Descriptor instead. func (*CopyEntryFieldResponse) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{48} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{50} } type GeneratePasswordRequest struct { @@ -2274,7 +2362,7 @@ type GeneratePasswordRequest struct { func (x *GeneratePasswordRequest) Reset() { *x = GeneratePasswordRequest{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[49] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2286,7 +2374,7 @@ func (x *GeneratePasswordRequest) String() string { func (*GeneratePasswordRequest) ProtoMessage() {} func (x *GeneratePasswordRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[49] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2299,7 +2387,7 @@ func (x *GeneratePasswordRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GeneratePasswordRequest.ProtoReflect.Descriptor instead. func (*GeneratePasswordRequest) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{49} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{51} } func (x *GeneratePasswordRequest) GetProfile() string { @@ -2318,7 +2406,7 @@ type GeneratePasswordResponse struct { func (x *GeneratePasswordResponse) Reset() { *x = GeneratePasswordResponse{} - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[50] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2330,7 +2418,7 @@ func (x *GeneratePasswordResponse) String() string { func (*GeneratePasswordResponse) ProtoMessage() {} func (x *GeneratePasswordResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[50] + mi := &file_proto_keepassgo_v1_keepassgo_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2343,7 +2431,7 @@ func (x *GeneratePasswordResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GeneratePasswordResponse.ProtoReflect.Descriptor instead. func (*GeneratePasswordResponse) Descriptor() ([]byte, []int) { - return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{50} + return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{52} } func (x *GeneratePasswordResponse) GetPassword() string { @@ -2387,7 +2475,7 @@ const file_proto_keepassgo_v1_keepassgo_proto_rawDesc = "" + "\x13UnlockVaultResponse\">\n" + "\x12ListEntriesRequest\x12\x12\n" + "\x04path\x18\x01 \x03(\tR\x04path\x12\x14\n" + - "\x05query\x18\x02 \x01(\tR\x05query\"\xb5\x01\n" + + "\x05query\x18\x02 \x01(\tR\x05query\"\xa9\x02\n" + "\x05Entry\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + "\x05title\x18\x02 \x01(\tR\x05title\x12\x1a\n" + @@ -2396,7 +2484,11 @@ const file_proto_keepassgo_v1_keepassgo_proto_rawDesc = "" + "\x03url\x18\x05 \x01(\tR\x03url\x12\x14\n" + "\x05notes\x18\x06 \x01(\tR\x05notes\x12\x12\n" + "\x04tags\x18\a \x03(\tR\x04tags\x12\x12\n" + - "\x04path\x18\b \x03(\tR\x04path\"D\n" + + "\x04path\x18\b \x03(\tR\x04path\x127\n" + + "\x06fields\x18\t \x03(\v2\x1f.keepassgo.v1.Entry.FieldsEntryR\x06fields\x1a9\n" + + "\vFieldsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"D\n" + "\x13ListEntriesResponse\x12-\n" + "\aentries\x18\x01 \x03(\v2\x13.keepassgo.v1.EntryR\aentries\"'\n" + "\x11ListGroupsRequest\x12\x12\n" + @@ -2411,7 +2503,10 @@ const file_proto_keepassgo_v1_keepassgo_proto_rawDesc = "" + "\x12RenameGroupRequest\x12\x12\n" + "\x04path\x18\x01 \x03(\tR\x04path\x12\x19\n" + "\bnew_name\x18\x02 \x01(\tR\anewName\"\x15\n" + - "\x13RenameGroupResponse\"?\n" + + "\x13RenameGroupResponse\"(\n" + + "\x12DeleteGroupRequest\x12\x12\n" + + "\x04path\x18\x01 \x03(\tR\x04path\"\x15\n" + + "\x13DeleteGroupResponse\"?\n" + "\x12UpsertEntryRequest\x12)\n" + "\x05entry\x18\x01 \x01(\v2\x13.keepassgo.v1.EntryR\x05entry\"@\n" + "\x13UpsertEntryResponse\x12)\n" + @@ -2473,7 +2568,7 @@ const file_proto_keepassgo_v1_keepassgo_proto_rawDesc = "" + "\x17GeneratePasswordRequest\x12\x18\n" + "\aprofile\x18\x01 \x01(\tR\aprofile\"6\n" + "\x18GeneratePasswordResponse\x12\x1a\n" + - "\bpassword\x18\x01 \x01(\tR\bpassword2\xf9\x11\n" + + "\bpassword\x18\x01 \x01(\tR\bpassword2\xcd\x12\n" + "\fVaultService\x12a\n" + "\x10GetSessionStatus\x12%.keepassgo.v1.GetSessionStatusRequest\x1a&.keepassgo.v1.GetSessionStatusResponse\x12L\n" + "\tOpenVault\x12\x1e.keepassgo.v1.OpenVaultRequest\x1a\x1f.keepassgo.v1.OpenVaultResponse\x12^\n" + @@ -2486,6 +2581,7 @@ const file_proto_keepassgo_v1_keepassgo_proto_rawDesc = "" + "ListGroups\x12\x1f.keepassgo.v1.ListGroupsRequest\x1a .keepassgo.v1.ListGroupsResponse\x12R\n" + "\vCreateGroup\x12 .keepassgo.v1.CreateGroupRequest\x1a!.keepassgo.v1.CreateGroupResponse\x12R\n" + "\vRenameGroup\x12 .keepassgo.v1.RenameGroupRequest\x1a!.keepassgo.v1.RenameGroupResponse\x12R\n" + + "\vDeleteGroup\x12 .keepassgo.v1.DeleteGroupRequest\x1a!.keepassgo.v1.DeleteGroupResponse\x12R\n" + "\vUpsertEntry\x12 .keepassgo.v1.UpsertEntryRequest\x1a!.keepassgo.v1.UpsertEntryResponse\x12R\n" + "\vDeleteEntry\x12 .keepassgo.v1.DeleteEntryRequest\x1a!.keepassgo.v1.DeleteEntryResponse\x12U\n" + "\fRestoreEntry\x12!.keepassgo.v1.RestoreEntryRequest\x1a\".keepassgo.v1.RestoreEntryResponse\x12a\n" + @@ -2514,7 +2610,7 @@ func file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP() []byte { return file_proto_keepassgo_v1_keepassgo_proto_rawDescData } -var file_proto_keepassgo_v1_keepassgo_proto_msgTypes = make([]protoimpl.MessageInfo, 51) +var file_proto_keepassgo_v1_keepassgo_proto_msgTypes = make([]protoimpl.MessageInfo, 54) var file_proto_keepassgo_v1_keepassgo_proto_goTypes = []any{ (*GetSessionStatusRequest)(nil), // 0: keepassgo.v1.GetSessionStatusRequest (*GetSessionStatusResponse)(nil), // 1: keepassgo.v1.GetSessionStatusResponse @@ -2537,104 +2633,110 @@ var file_proto_keepassgo_v1_keepassgo_proto_goTypes = []any{ (*CreateGroupResponse)(nil), // 18: keepassgo.v1.CreateGroupResponse (*RenameGroupRequest)(nil), // 19: keepassgo.v1.RenameGroupRequest (*RenameGroupResponse)(nil), // 20: keepassgo.v1.RenameGroupResponse - (*UpsertEntryRequest)(nil), // 21: keepassgo.v1.UpsertEntryRequest - (*UpsertEntryResponse)(nil), // 22: keepassgo.v1.UpsertEntryResponse - (*DeleteEntryRequest)(nil), // 23: keepassgo.v1.DeleteEntryRequest - (*DeleteEntryResponse)(nil), // 24: keepassgo.v1.DeleteEntryResponse - (*RestoreEntryRequest)(nil), // 25: keepassgo.v1.RestoreEntryRequest - (*RestoreEntryResponse)(nil), // 26: keepassgo.v1.RestoreEntryResponse - (*ListEntryHistoryRequest)(nil), // 27: keepassgo.v1.ListEntryHistoryRequest - (*ListEntryHistoryResponse)(nil), // 28: keepassgo.v1.ListEntryHistoryResponse - (*RestoreEntryHistoryRequest)(nil), // 29: keepassgo.v1.RestoreEntryHistoryRequest - (*RestoreEntryHistoryResponse)(nil), // 30: keepassgo.v1.RestoreEntryHistoryResponse - (*ListTemplatesRequest)(nil), // 31: keepassgo.v1.ListTemplatesRequest - (*ListTemplatesResponse)(nil), // 32: keepassgo.v1.ListTemplatesResponse - (*UpsertTemplateRequest)(nil), // 33: keepassgo.v1.UpsertTemplateRequest - (*UpsertTemplateResponse)(nil), // 34: keepassgo.v1.UpsertTemplateResponse - (*DeleteTemplateRequest)(nil), // 35: keepassgo.v1.DeleteTemplateRequest - (*DeleteTemplateResponse)(nil), // 36: keepassgo.v1.DeleteTemplateResponse - (*InstantiateTemplateRequest)(nil), // 37: keepassgo.v1.InstantiateTemplateRequest - (*InstantiateTemplateResponse)(nil), // 38: keepassgo.v1.InstantiateTemplateResponse - (*ListAttachmentsRequest)(nil), // 39: keepassgo.v1.ListAttachmentsRequest - (*ListAttachmentsResponse)(nil), // 40: keepassgo.v1.ListAttachmentsResponse - (*UploadAttachmentRequest)(nil), // 41: keepassgo.v1.UploadAttachmentRequest - (*UploadAttachmentResponse)(nil), // 42: keepassgo.v1.UploadAttachmentResponse - (*DownloadAttachmentRequest)(nil), // 43: keepassgo.v1.DownloadAttachmentRequest - (*DownloadAttachmentResponse)(nil), // 44: keepassgo.v1.DownloadAttachmentResponse - (*DeleteAttachmentRequest)(nil), // 45: keepassgo.v1.DeleteAttachmentRequest - (*DeleteAttachmentResponse)(nil), // 46: keepassgo.v1.DeleteAttachmentResponse - (*CopyEntryFieldRequest)(nil), // 47: keepassgo.v1.CopyEntryFieldRequest - (*CopyEntryFieldResponse)(nil), // 48: keepassgo.v1.CopyEntryFieldResponse - (*GeneratePasswordRequest)(nil), // 49: keepassgo.v1.GeneratePasswordRequest - (*GeneratePasswordResponse)(nil), // 50: keepassgo.v1.GeneratePasswordResponse + (*DeleteGroupRequest)(nil), // 21: keepassgo.v1.DeleteGroupRequest + (*DeleteGroupResponse)(nil), // 22: keepassgo.v1.DeleteGroupResponse + (*UpsertEntryRequest)(nil), // 23: keepassgo.v1.UpsertEntryRequest + (*UpsertEntryResponse)(nil), // 24: keepassgo.v1.UpsertEntryResponse + (*DeleteEntryRequest)(nil), // 25: keepassgo.v1.DeleteEntryRequest + (*DeleteEntryResponse)(nil), // 26: keepassgo.v1.DeleteEntryResponse + (*RestoreEntryRequest)(nil), // 27: keepassgo.v1.RestoreEntryRequest + (*RestoreEntryResponse)(nil), // 28: keepassgo.v1.RestoreEntryResponse + (*ListEntryHistoryRequest)(nil), // 29: keepassgo.v1.ListEntryHistoryRequest + (*ListEntryHistoryResponse)(nil), // 30: keepassgo.v1.ListEntryHistoryResponse + (*RestoreEntryHistoryRequest)(nil), // 31: keepassgo.v1.RestoreEntryHistoryRequest + (*RestoreEntryHistoryResponse)(nil), // 32: keepassgo.v1.RestoreEntryHistoryResponse + (*ListTemplatesRequest)(nil), // 33: keepassgo.v1.ListTemplatesRequest + (*ListTemplatesResponse)(nil), // 34: keepassgo.v1.ListTemplatesResponse + (*UpsertTemplateRequest)(nil), // 35: keepassgo.v1.UpsertTemplateRequest + (*UpsertTemplateResponse)(nil), // 36: keepassgo.v1.UpsertTemplateResponse + (*DeleteTemplateRequest)(nil), // 37: keepassgo.v1.DeleteTemplateRequest + (*DeleteTemplateResponse)(nil), // 38: keepassgo.v1.DeleteTemplateResponse + (*InstantiateTemplateRequest)(nil), // 39: keepassgo.v1.InstantiateTemplateRequest + (*InstantiateTemplateResponse)(nil), // 40: keepassgo.v1.InstantiateTemplateResponse + (*ListAttachmentsRequest)(nil), // 41: keepassgo.v1.ListAttachmentsRequest + (*ListAttachmentsResponse)(nil), // 42: keepassgo.v1.ListAttachmentsResponse + (*UploadAttachmentRequest)(nil), // 43: keepassgo.v1.UploadAttachmentRequest + (*UploadAttachmentResponse)(nil), // 44: keepassgo.v1.UploadAttachmentResponse + (*DownloadAttachmentRequest)(nil), // 45: keepassgo.v1.DownloadAttachmentRequest + (*DownloadAttachmentResponse)(nil), // 46: keepassgo.v1.DownloadAttachmentResponse + (*DeleteAttachmentRequest)(nil), // 47: keepassgo.v1.DeleteAttachmentRequest + (*DeleteAttachmentResponse)(nil), // 48: keepassgo.v1.DeleteAttachmentResponse + (*CopyEntryFieldRequest)(nil), // 49: keepassgo.v1.CopyEntryFieldRequest + (*CopyEntryFieldResponse)(nil), // 50: keepassgo.v1.CopyEntryFieldResponse + (*GeneratePasswordRequest)(nil), // 51: keepassgo.v1.GeneratePasswordRequest + (*GeneratePasswordResponse)(nil), // 52: keepassgo.v1.GeneratePasswordResponse + nil, // 53: keepassgo.v1.Entry.FieldsEntry } var file_proto_keepassgo_v1_keepassgo_proto_depIdxs = []int32{ - 13, // 0: keepassgo.v1.ListEntriesResponse.entries:type_name -> keepassgo.v1.Entry - 13, // 1: keepassgo.v1.UpsertEntryRequest.entry:type_name -> keepassgo.v1.Entry - 13, // 2: keepassgo.v1.UpsertEntryResponse.entry:type_name -> keepassgo.v1.Entry - 13, // 3: keepassgo.v1.RestoreEntryResponse.entry:type_name -> keepassgo.v1.Entry - 13, // 4: keepassgo.v1.ListEntryHistoryResponse.entries:type_name -> keepassgo.v1.Entry - 13, // 5: keepassgo.v1.RestoreEntryHistoryResponse.entry:type_name -> keepassgo.v1.Entry - 13, // 6: keepassgo.v1.ListTemplatesResponse.templates:type_name -> keepassgo.v1.Entry - 13, // 7: keepassgo.v1.UpsertTemplateRequest.template:type_name -> keepassgo.v1.Entry - 13, // 8: keepassgo.v1.UpsertTemplateResponse.template:type_name -> keepassgo.v1.Entry - 13, // 9: keepassgo.v1.InstantiateTemplateRequest.overrides:type_name -> keepassgo.v1.Entry - 13, // 10: keepassgo.v1.InstantiateTemplateResponse.entry:type_name -> keepassgo.v1.Entry - 0, // 11: keepassgo.v1.VaultService.GetSessionStatus:input_type -> keepassgo.v1.GetSessionStatusRequest - 2, // 12: keepassgo.v1.VaultService.OpenVault:input_type -> keepassgo.v1.OpenVaultRequest - 4, // 13: keepassgo.v1.VaultService.OpenRemoteVault:input_type -> keepassgo.v1.OpenRemoteVaultRequest - 6, // 14: keepassgo.v1.VaultService.SaveVault:input_type -> keepassgo.v1.SaveVaultRequest - 8, // 15: keepassgo.v1.VaultService.LockVault:input_type -> keepassgo.v1.LockVaultRequest - 10, // 16: keepassgo.v1.VaultService.UnlockVault:input_type -> keepassgo.v1.UnlockVaultRequest - 12, // 17: keepassgo.v1.VaultService.ListEntries:input_type -> keepassgo.v1.ListEntriesRequest - 15, // 18: keepassgo.v1.VaultService.ListGroups:input_type -> keepassgo.v1.ListGroupsRequest - 17, // 19: keepassgo.v1.VaultService.CreateGroup:input_type -> keepassgo.v1.CreateGroupRequest - 19, // 20: keepassgo.v1.VaultService.RenameGroup:input_type -> keepassgo.v1.RenameGroupRequest - 21, // 21: keepassgo.v1.VaultService.UpsertEntry:input_type -> keepassgo.v1.UpsertEntryRequest - 23, // 22: keepassgo.v1.VaultService.DeleteEntry:input_type -> keepassgo.v1.DeleteEntryRequest - 25, // 23: keepassgo.v1.VaultService.RestoreEntry:input_type -> keepassgo.v1.RestoreEntryRequest - 27, // 24: keepassgo.v1.VaultService.ListEntryHistory:input_type -> keepassgo.v1.ListEntryHistoryRequest - 29, // 25: keepassgo.v1.VaultService.RestoreEntryHistory:input_type -> keepassgo.v1.RestoreEntryHistoryRequest - 31, // 26: keepassgo.v1.VaultService.ListTemplates:input_type -> keepassgo.v1.ListTemplatesRequest - 33, // 27: keepassgo.v1.VaultService.UpsertTemplate:input_type -> keepassgo.v1.UpsertTemplateRequest - 35, // 28: keepassgo.v1.VaultService.DeleteTemplate:input_type -> keepassgo.v1.DeleteTemplateRequest - 37, // 29: keepassgo.v1.VaultService.InstantiateTemplate:input_type -> keepassgo.v1.InstantiateTemplateRequest - 39, // 30: keepassgo.v1.VaultService.ListAttachments:input_type -> keepassgo.v1.ListAttachmentsRequest - 41, // 31: keepassgo.v1.VaultService.UploadAttachment:input_type -> keepassgo.v1.UploadAttachmentRequest - 43, // 32: keepassgo.v1.VaultService.DownloadAttachment:input_type -> keepassgo.v1.DownloadAttachmentRequest - 45, // 33: keepassgo.v1.VaultService.DeleteAttachment:input_type -> keepassgo.v1.DeleteAttachmentRequest - 47, // 34: keepassgo.v1.VaultService.CopyEntryField:input_type -> keepassgo.v1.CopyEntryFieldRequest - 49, // 35: keepassgo.v1.VaultService.GeneratePassword:input_type -> keepassgo.v1.GeneratePasswordRequest - 1, // 36: keepassgo.v1.VaultService.GetSessionStatus:output_type -> keepassgo.v1.GetSessionStatusResponse - 3, // 37: keepassgo.v1.VaultService.OpenVault:output_type -> keepassgo.v1.OpenVaultResponse - 5, // 38: keepassgo.v1.VaultService.OpenRemoteVault:output_type -> keepassgo.v1.OpenRemoteVaultResponse - 7, // 39: keepassgo.v1.VaultService.SaveVault:output_type -> keepassgo.v1.SaveVaultResponse - 9, // 40: keepassgo.v1.VaultService.LockVault:output_type -> keepassgo.v1.LockVaultResponse - 11, // 41: keepassgo.v1.VaultService.UnlockVault:output_type -> keepassgo.v1.UnlockVaultResponse - 14, // 42: keepassgo.v1.VaultService.ListEntries:output_type -> keepassgo.v1.ListEntriesResponse - 16, // 43: keepassgo.v1.VaultService.ListGroups:output_type -> keepassgo.v1.ListGroupsResponse - 18, // 44: keepassgo.v1.VaultService.CreateGroup:output_type -> keepassgo.v1.CreateGroupResponse - 20, // 45: keepassgo.v1.VaultService.RenameGroup:output_type -> keepassgo.v1.RenameGroupResponse - 22, // 46: keepassgo.v1.VaultService.UpsertEntry:output_type -> keepassgo.v1.UpsertEntryResponse - 24, // 47: keepassgo.v1.VaultService.DeleteEntry:output_type -> keepassgo.v1.DeleteEntryResponse - 26, // 48: keepassgo.v1.VaultService.RestoreEntry:output_type -> keepassgo.v1.RestoreEntryResponse - 28, // 49: keepassgo.v1.VaultService.ListEntryHistory:output_type -> keepassgo.v1.ListEntryHistoryResponse - 30, // 50: keepassgo.v1.VaultService.RestoreEntryHistory:output_type -> keepassgo.v1.RestoreEntryHistoryResponse - 32, // 51: keepassgo.v1.VaultService.ListTemplates:output_type -> keepassgo.v1.ListTemplatesResponse - 34, // 52: keepassgo.v1.VaultService.UpsertTemplate:output_type -> keepassgo.v1.UpsertTemplateResponse - 36, // 53: keepassgo.v1.VaultService.DeleteTemplate:output_type -> keepassgo.v1.DeleteTemplateResponse - 38, // 54: keepassgo.v1.VaultService.InstantiateTemplate:output_type -> keepassgo.v1.InstantiateTemplateResponse - 40, // 55: keepassgo.v1.VaultService.ListAttachments:output_type -> keepassgo.v1.ListAttachmentsResponse - 42, // 56: keepassgo.v1.VaultService.UploadAttachment:output_type -> keepassgo.v1.UploadAttachmentResponse - 44, // 57: keepassgo.v1.VaultService.DownloadAttachment:output_type -> keepassgo.v1.DownloadAttachmentResponse - 46, // 58: keepassgo.v1.VaultService.DeleteAttachment:output_type -> keepassgo.v1.DeleteAttachmentResponse - 48, // 59: keepassgo.v1.VaultService.CopyEntryField:output_type -> keepassgo.v1.CopyEntryFieldResponse - 50, // 60: keepassgo.v1.VaultService.GeneratePassword:output_type -> keepassgo.v1.GeneratePasswordResponse - 36, // [36:61] is the sub-list for method output_type - 11, // [11:36] is the sub-list for method input_type - 11, // [11:11] is the sub-list for extension type_name - 11, // [11:11] is the sub-list for extension extendee - 0, // [0:11] is the sub-list for field type_name + 53, // 0: keepassgo.v1.Entry.fields:type_name -> keepassgo.v1.Entry.FieldsEntry + 13, // 1: keepassgo.v1.ListEntriesResponse.entries:type_name -> keepassgo.v1.Entry + 13, // 2: keepassgo.v1.UpsertEntryRequest.entry:type_name -> keepassgo.v1.Entry + 13, // 3: keepassgo.v1.UpsertEntryResponse.entry:type_name -> keepassgo.v1.Entry + 13, // 4: keepassgo.v1.RestoreEntryResponse.entry:type_name -> keepassgo.v1.Entry + 13, // 5: keepassgo.v1.ListEntryHistoryResponse.entries:type_name -> keepassgo.v1.Entry + 13, // 6: keepassgo.v1.RestoreEntryHistoryResponse.entry:type_name -> keepassgo.v1.Entry + 13, // 7: keepassgo.v1.ListTemplatesResponse.templates:type_name -> keepassgo.v1.Entry + 13, // 8: keepassgo.v1.UpsertTemplateRequest.template:type_name -> keepassgo.v1.Entry + 13, // 9: keepassgo.v1.UpsertTemplateResponse.template:type_name -> keepassgo.v1.Entry + 13, // 10: keepassgo.v1.InstantiateTemplateRequest.overrides:type_name -> keepassgo.v1.Entry + 13, // 11: keepassgo.v1.InstantiateTemplateResponse.entry:type_name -> keepassgo.v1.Entry + 0, // 12: keepassgo.v1.VaultService.GetSessionStatus:input_type -> keepassgo.v1.GetSessionStatusRequest + 2, // 13: keepassgo.v1.VaultService.OpenVault:input_type -> keepassgo.v1.OpenVaultRequest + 4, // 14: keepassgo.v1.VaultService.OpenRemoteVault:input_type -> keepassgo.v1.OpenRemoteVaultRequest + 6, // 15: keepassgo.v1.VaultService.SaveVault:input_type -> keepassgo.v1.SaveVaultRequest + 8, // 16: keepassgo.v1.VaultService.LockVault:input_type -> keepassgo.v1.LockVaultRequest + 10, // 17: keepassgo.v1.VaultService.UnlockVault:input_type -> keepassgo.v1.UnlockVaultRequest + 12, // 18: keepassgo.v1.VaultService.ListEntries:input_type -> keepassgo.v1.ListEntriesRequest + 15, // 19: keepassgo.v1.VaultService.ListGroups:input_type -> keepassgo.v1.ListGroupsRequest + 17, // 20: keepassgo.v1.VaultService.CreateGroup:input_type -> keepassgo.v1.CreateGroupRequest + 19, // 21: keepassgo.v1.VaultService.RenameGroup:input_type -> keepassgo.v1.RenameGroupRequest + 21, // 22: keepassgo.v1.VaultService.DeleteGroup:input_type -> keepassgo.v1.DeleteGroupRequest + 23, // 23: keepassgo.v1.VaultService.UpsertEntry:input_type -> keepassgo.v1.UpsertEntryRequest + 25, // 24: keepassgo.v1.VaultService.DeleteEntry:input_type -> keepassgo.v1.DeleteEntryRequest + 27, // 25: keepassgo.v1.VaultService.RestoreEntry:input_type -> keepassgo.v1.RestoreEntryRequest + 29, // 26: keepassgo.v1.VaultService.ListEntryHistory:input_type -> keepassgo.v1.ListEntryHistoryRequest + 31, // 27: keepassgo.v1.VaultService.RestoreEntryHistory:input_type -> keepassgo.v1.RestoreEntryHistoryRequest + 33, // 28: keepassgo.v1.VaultService.ListTemplates:input_type -> keepassgo.v1.ListTemplatesRequest + 35, // 29: keepassgo.v1.VaultService.UpsertTemplate:input_type -> keepassgo.v1.UpsertTemplateRequest + 37, // 30: keepassgo.v1.VaultService.DeleteTemplate:input_type -> keepassgo.v1.DeleteTemplateRequest + 39, // 31: keepassgo.v1.VaultService.InstantiateTemplate:input_type -> keepassgo.v1.InstantiateTemplateRequest + 41, // 32: keepassgo.v1.VaultService.ListAttachments:input_type -> keepassgo.v1.ListAttachmentsRequest + 43, // 33: keepassgo.v1.VaultService.UploadAttachment:input_type -> keepassgo.v1.UploadAttachmentRequest + 45, // 34: keepassgo.v1.VaultService.DownloadAttachment:input_type -> keepassgo.v1.DownloadAttachmentRequest + 47, // 35: keepassgo.v1.VaultService.DeleteAttachment:input_type -> keepassgo.v1.DeleteAttachmentRequest + 49, // 36: keepassgo.v1.VaultService.CopyEntryField:input_type -> keepassgo.v1.CopyEntryFieldRequest + 51, // 37: keepassgo.v1.VaultService.GeneratePassword:input_type -> keepassgo.v1.GeneratePasswordRequest + 1, // 38: keepassgo.v1.VaultService.GetSessionStatus:output_type -> keepassgo.v1.GetSessionStatusResponse + 3, // 39: keepassgo.v1.VaultService.OpenVault:output_type -> keepassgo.v1.OpenVaultResponse + 5, // 40: keepassgo.v1.VaultService.OpenRemoteVault:output_type -> keepassgo.v1.OpenRemoteVaultResponse + 7, // 41: keepassgo.v1.VaultService.SaveVault:output_type -> keepassgo.v1.SaveVaultResponse + 9, // 42: keepassgo.v1.VaultService.LockVault:output_type -> keepassgo.v1.LockVaultResponse + 11, // 43: keepassgo.v1.VaultService.UnlockVault:output_type -> keepassgo.v1.UnlockVaultResponse + 14, // 44: keepassgo.v1.VaultService.ListEntries:output_type -> keepassgo.v1.ListEntriesResponse + 16, // 45: keepassgo.v1.VaultService.ListGroups:output_type -> keepassgo.v1.ListGroupsResponse + 18, // 46: keepassgo.v1.VaultService.CreateGroup:output_type -> keepassgo.v1.CreateGroupResponse + 20, // 47: keepassgo.v1.VaultService.RenameGroup:output_type -> keepassgo.v1.RenameGroupResponse + 22, // 48: keepassgo.v1.VaultService.DeleteGroup:output_type -> keepassgo.v1.DeleteGroupResponse + 24, // 49: keepassgo.v1.VaultService.UpsertEntry:output_type -> keepassgo.v1.UpsertEntryResponse + 26, // 50: keepassgo.v1.VaultService.DeleteEntry:output_type -> keepassgo.v1.DeleteEntryResponse + 28, // 51: keepassgo.v1.VaultService.RestoreEntry:output_type -> keepassgo.v1.RestoreEntryResponse + 30, // 52: keepassgo.v1.VaultService.ListEntryHistory:output_type -> keepassgo.v1.ListEntryHistoryResponse + 32, // 53: keepassgo.v1.VaultService.RestoreEntryHistory:output_type -> keepassgo.v1.RestoreEntryHistoryResponse + 34, // 54: keepassgo.v1.VaultService.ListTemplates:output_type -> keepassgo.v1.ListTemplatesResponse + 36, // 55: keepassgo.v1.VaultService.UpsertTemplate:output_type -> keepassgo.v1.UpsertTemplateResponse + 38, // 56: keepassgo.v1.VaultService.DeleteTemplate:output_type -> keepassgo.v1.DeleteTemplateResponse + 40, // 57: keepassgo.v1.VaultService.InstantiateTemplate:output_type -> keepassgo.v1.InstantiateTemplateResponse + 42, // 58: keepassgo.v1.VaultService.ListAttachments:output_type -> keepassgo.v1.ListAttachmentsResponse + 44, // 59: keepassgo.v1.VaultService.UploadAttachment:output_type -> keepassgo.v1.UploadAttachmentResponse + 46, // 60: keepassgo.v1.VaultService.DownloadAttachment:output_type -> keepassgo.v1.DownloadAttachmentResponse + 48, // 61: keepassgo.v1.VaultService.DeleteAttachment:output_type -> keepassgo.v1.DeleteAttachmentResponse + 50, // 62: keepassgo.v1.VaultService.CopyEntryField:output_type -> keepassgo.v1.CopyEntryFieldResponse + 52, // 63: keepassgo.v1.VaultService.GeneratePassword:output_type -> keepassgo.v1.GeneratePasswordResponse + 38, // [38:64] is the sub-list for method output_type + 12, // [12:38] is the sub-list for method input_type + 12, // [12:12] is the sub-list for extension type_name + 12, // [12:12] is the sub-list for extension extendee + 0, // [0:12] is the sub-list for field type_name } func init() { file_proto_keepassgo_v1_keepassgo_proto_init() } @@ -2648,7 +2750,7 @@ func file_proto_keepassgo_v1_keepassgo_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_keepassgo_v1_keepassgo_proto_rawDesc), len(file_proto_keepassgo_v1_keepassgo_proto_rawDesc)), NumEnums: 0, - NumMessages: 51, + NumMessages: 54, NumExtensions: 0, NumServices: 1, }, diff --git a/proto/keepassgo/v1/keepassgo.proto b/proto/keepassgo/v1/keepassgo.proto index f8d1583..ef57ec8 100644 --- a/proto/keepassgo/v1/keepassgo.proto +++ b/proto/keepassgo/v1/keepassgo.proto @@ -15,6 +15,7 @@ service VaultService { rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse); rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse); rpc RenameGroup(RenameGroupRequest) returns (RenameGroupResponse); + rpc DeleteGroup(DeleteGroupRequest) returns (DeleteGroupResponse); rpc UpsertEntry(UpsertEntryRequest) returns (UpsertEntryResponse); rpc DeleteEntry(DeleteEntryRequest) returns (DeleteEntryResponse); rpc RestoreEntry(RestoreEntryRequest) returns (RestoreEntryResponse); @@ -88,6 +89,7 @@ message Entry { string notes = 6; repeated string tags = 7; repeated string path = 8; + map fields = 9; } message ListEntriesResponse { @@ -116,6 +118,12 @@ message RenameGroupRequest { message RenameGroupResponse {} +message DeleteGroupRequest { + repeated string path = 1; +} + +message DeleteGroupResponse {} + message UpsertEntryRequest { Entry entry = 1; } diff --git a/proto/keepassgo/v1/keepassgo_grpc.pb.go b/proto/keepassgo/v1/keepassgo_grpc.pb.go index 2eb4e34..2420a58 100644 --- a/proto/keepassgo/v1/keepassgo_grpc.pb.go +++ b/proto/keepassgo/v1/keepassgo_grpc.pb.go @@ -29,6 +29,7 @@ const ( VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups" VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup" VaultService_RenameGroup_FullMethodName = "/keepassgo.v1.VaultService/RenameGroup" + VaultService_DeleteGroup_FullMethodName = "/keepassgo.v1.VaultService/DeleteGroup" VaultService_UpsertEntry_FullMethodName = "/keepassgo.v1.VaultService/UpsertEntry" VaultService_DeleteEntry_FullMethodName = "/keepassgo.v1.VaultService/DeleteEntry" VaultService_RestoreEntry_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntry" @@ -60,6 +61,7 @@ type VaultServiceClient interface { ListGroups(ctx context.Context, in *ListGroupsRequest, opts ...grpc.CallOption) (*ListGroupsResponse, error) CreateGroup(ctx context.Context, in *CreateGroupRequest, opts ...grpc.CallOption) (*CreateGroupResponse, error) RenameGroup(ctx context.Context, in *RenameGroupRequest, opts ...grpc.CallOption) (*RenameGroupResponse, error) + DeleteGroup(ctx context.Context, in *DeleteGroupRequest, opts ...grpc.CallOption) (*DeleteGroupResponse, error) UpsertEntry(ctx context.Context, in *UpsertEntryRequest, opts ...grpc.CallOption) (*UpsertEntryResponse, error) DeleteEntry(ctx context.Context, in *DeleteEntryRequest, opts ...grpc.CallOption) (*DeleteEntryResponse, error) RestoreEntry(ctx context.Context, in *RestoreEntryRequest, opts ...grpc.CallOption) (*RestoreEntryResponse, error) @@ -185,6 +187,16 @@ func (c *vaultServiceClient) RenameGroup(ctx context.Context, in *RenameGroupReq return out, nil } +func (c *vaultServiceClient) DeleteGroup(ctx context.Context, in *DeleteGroupRequest, opts ...grpc.CallOption) (*DeleteGroupResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteGroupResponse) + err := c.cc.Invoke(ctx, VaultService_DeleteGroup_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *vaultServiceClient) UpsertEntry(ctx context.Context, in *UpsertEntryRequest, opts ...grpc.CallOption) (*UpsertEntryResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(UpsertEntryResponse) @@ -349,6 +361,7 @@ type VaultServiceServer interface { ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error) CreateGroup(context.Context, *CreateGroupRequest) (*CreateGroupResponse, error) RenameGroup(context.Context, *RenameGroupRequest) (*RenameGroupResponse, error) + DeleteGroup(context.Context, *DeleteGroupRequest) (*DeleteGroupResponse, error) UpsertEntry(context.Context, *UpsertEntryRequest) (*UpsertEntryResponse, error) DeleteEntry(context.Context, *DeleteEntryRequest) (*DeleteEntryResponse, error) RestoreEntry(context.Context, *RestoreEntryRequest) (*RestoreEntryResponse, error) @@ -404,6 +417,9 @@ func (UnimplementedVaultServiceServer) CreateGroup(context.Context, *CreateGroup func (UnimplementedVaultServiceServer) RenameGroup(context.Context, *RenameGroupRequest) (*RenameGroupResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method RenameGroup not implemented") } +func (UnimplementedVaultServiceServer) DeleteGroup(context.Context, *DeleteGroupRequest) (*DeleteGroupResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeleteGroup not implemented") +} func (UnimplementedVaultServiceServer) UpsertEntry(context.Context, *UpsertEntryRequest) (*UpsertEntryResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method UpsertEntry not implemented") } @@ -650,6 +666,24 @@ func _VaultService_RenameGroup_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _VaultService_DeleteGroup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteGroupRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).DeleteGroup(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_DeleteGroup_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).DeleteGroup(ctx, req.(*DeleteGroupRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _VaultService_UpsertEntry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(UpsertEntryRequest) if err := dec(in); err != nil { @@ -967,6 +1001,10 @@ var VaultService_ServiceDesc = grpc.ServiceDesc{ MethodName: "RenameGroup", Handler: _VaultService_RenameGroup_Handler, }, + { + MethodName: "DeleteGroup", + Handler: _VaultService_DeleteGroup_Handler, + }, { MethodName: "UpsertEntry", Handler: _VaultService_UpsertEntry_Handler, diff --git a/vault/model.go b/vault/model.go index 911d80a..9295bb9 100644 --- a/vault/model.go +++ b/vault/model.go @@ -7,19 +7,20 @@ import ( ) var ErrEntryNotFound = errors.New("entry not found") +var ErrGroupNotEmpty = errors.New("group is not empty") type Entry struct { - ID string - Title string - Username string - Password string - URL string - Notes string - Tags []string - Fields map[string]string + ID string + Title string + Username string + Password string + URL string + Notes string + Tags []string + Fields map[string]string Attachments map[string][]byte - History []Entry - Path []string + History []Entry + Path []string } type SearchResult struct { @@ -323,12 +324,12 @@ func (m *Model) MoveTemplate(id string, path []string) error { func (m *Model) DeleteGroup(path []string) error { for _, entry := range m.Entries { if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) { - return errors.New("group is not empty") + return ErrGroupNotEmpty } } for _, entry := range m.Templates { if slices.Equal(entry.Path, path) || hasPathPrefix(entry.Path, path) { - return errors.New("group is not empty") + return ErrGroupNotEmpty } } From 6c5e9b42d3c083bd6d2d53077001d0291b878fc0 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:21:52 -0700 Subject: [PATCH 10/13] Add gRPC vault lifecycle backend flow --- api/server.go | 68 +++++-- api/server_test.go | 289 ++++++++++++++++++++++++++++- proto/keepassgo/v1/keepassgo.pb.go | 22 ++- proto/keepassgo/v1/keepassgo.proto | 5 +- 4 files changed, 358 insertions(+), 26 deletions(-) diff --git a/api/server.go b/api/server.go index 157cec5..6aa6b34 100644 --- a/api/server.go +++ b/api/server.go @@ -4,15 +4,17 @@ import ( "context" "errors" "maps" + "os" "slices" - "sync" "strings" + "sync" "git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/passwords" keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1" - "git.julianfamily.org/keepassgo/webdav" + "git.julianfamily.org/keepassgo/session" "git.julianfamily.org/keepassgo/vault" + "git.julianfamily.org/keepassgo/webdav" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" @@ -36,6 +38,8 @@ type lifecycleBackend interface { Open(string, vault.MasterKey) error OpenRemote(webdav.Client, string, vault.MasterKey) error Save() error + Lock() error + Unlock(vault.MasterKey) error } func NewServer(model vault.Model, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer) *Server { @@ -57,8 +61,8 @@ func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionSt defer s.mu.RUnlock() return &keepassgov1.GetSessionStatusResponse{ - Locked: s.locked, - Dirty: s.dirty, + Locked: s.locked, + Dirty: s.dirty, EntryCount: uint32(len(s.model.Entries)), }, nil } @@ -70,12 +74,12 @@ func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest) key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)} if err := s.lifecycle.Open(req.GetPath(), key); err != nil { - return nil, status.Errorf(codes.Internal, "open vault: %v", err) + return nil, mapLifecycleError("open vault", err) } model, err := s.lifecycle.Current() if err != nil { - return nil, status.Errorf(codes.Internal, "load opened vault: %v", err) + return nil, mapLifecycleError("load opened vault", err) } s.mu.Lock() @@ -99,12 +103,12 @@ func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteV } key := vault.MasterKey{Password: req.GetMasterPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)} if err := s.lifecycle.OpenRemote(client, req.GetPath(), key); err != nil { - return nil, status.Errorf(codes.Internal, "open remote vault: %v", err) + return nil, mapLifecycleError("open remote vault", err) } model, err := s.lifecycle.Current() if err != nil { - return nil, status.Errorf(codes.Internal, "load opened remote vault: %v", err) + return nil, mapLifecycleError("load opened remote vault", err) } s.mu.Lock() @@ -122,7 +126,7 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) ( } if err := s.lifecycle.Save(); err != nil { - return nil, status.Errorf(codes.Internal, "save vault: %v", err) + return nil, mapLifecycleError("save vault", err) } s.mu.Lock() @@ -133,23 +137,59 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) ( } func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) { - s.mu.Lock() - defer s.mu.Unlock() + if s.lifecycle == nil { + return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured") + } + if err := s.lifecycle.Lock(); err != nil { + return nil, mapLifecycleError("lock vault", err) + } + + s.mu.Lock() s.locked = true + s.mu.Unlock() return &keepassgov1.LockVaultResponse{}, nil } -func (s *Server) UnlockVault(_ context.Context, _ *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) { - s.mu.Lock() - defer s.mu.Unlock() +func (s *Server) UnlockVault(_ context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) { + if s.lifecycle == nil { + return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured") + } + key := vault.MasterKey{Password: req.GetPassword(), KeyFileData: append([]byte(nil), req.GetKeyFileData()...)} + if err := s.lifecycle.Unlock(key); err != nil { + return nil, mapLifecycleError("unlock vault", err) + } + + model, err := s.lifecycle.Current() + if err != nil { + return nil, mapLifecycleError("load unlocked vault", err) + } + + s.mu.Lock() + s.model = model s.locked = false + s.mu.Unlock() return &keepassgov1.UnlockVaultResponse{}, nil } +func mapLifecycleError(operation string, err error) error { + switch { + case errors.Is(err, os.ErrNotExist): + return status.Errorf(codes.NotFound, "%s: %v", operation, err) + case errors.Is(err, vault.ErrInvalidMasterKey): + return status.Errorf(codes.InvalidArgument, "%s: %v", operation, err) + case errors.Is(err, session.ErrLocked), errors.Is(err, session.ErrNoPath): + return status.Errorf(codes.FailedPrecondition, "%s: %v", operation, err) + case errors.Is(err, webdav.ErrConflict): + return status.Errorf(codes.Aborted, "%s: %v", operation, err) + default: + return status.Errorf(codes.Internal, "%s: %v", operation, err) + } +} + func (s *Server) ListEntries(_ context.Context, req *keepassgov1.ListEntriesRequest) (*keepassgov1.ListEntriesResponse, error) { s.mu.RLock() defer s.mu.RUnlock() diff --git a/api/server_test.go b/api/server_test.go index d12494c..02500fb 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "net" + "os" "testing" "git.julianfamily.org/keepassgo/passwords" @@ -34,7 +35,21 @@ func TestVaultServiceRejectsRequestsWithoutBearerToken(t *testing.T) { func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) { t.Parallel() - client, _, cleanup := newTestClient(t) + lifecycle := &stubLifecycle{ + model: vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + }, + } + client, _, cleanup := newTestClientWithLifecycle(t, lifecycle) defer cleanup() ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") @@ -78,6 +93,82 @@ func TestVaultServiceReportsSessionStatusAndSupportsLockUnlock(t *testing.T) { } } +func TestVaultServiceLockAndUnlockUseLifecycleBackend(t *testing.T) { + t.Parallel() + + lifecycle := &stubLifecycle{ + model: vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Remote Git", Path: []string{"Root", "Internet"}}, + }, + }, + unlockPassword: "correct horse battery staple", + unlockKeyFile: []byte("key-material"), + } + client, _, cleanup := newTestClientWithLifecycle(t, lifecycle) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + if _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{ + Path: "/tmp/test.kdbx", + Password: lifecycle.unlockPassword, + KeyFileData: lifecycle.unlockKeyFile, + }); err != nil { + t.Fatalf("OpenVault() error = %v", err) + } + + if _, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{}); err != nil { + t.Fatalf("LockVault() error = %v", err) + } + if !lifecycle.locked { + t.Fatal("LockVault() did not lock lifecycle backend") + } + + statusResp, err := client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{}) + if err != nil { + t.Fatalf("GetSessionStatus() after lock error = %v", err) + } + if !statusResp.Locked { + t.Fatal("GetSessionStatus().Locked = false, want true after lock") + } + + if _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{ + Password: "wrong password", + KeyFileData: lifecycle.unlockKeyFile, + }); status.Code(err) != codes.InvalidArgument { + t.Fatalf("UnlockVault(wrong password) code = %v, want %v", status.Code(err), codes.InvalidArgument) + } + + statusResp, err = client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{}) + if err != nil { + t.Fatalf("GetSessionStatus() after failed unlock error = %v", err) + } + if !statusResp.Locked { + t.Fatal("GetSessionStatus().Locked = false, want true after failed unlock") + } + + if _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{ + Password: lifecycle.unlockPassword, + KeyFileData: lifecycle.unlockKeyFile, + }); err != nil { + t.Fatalf("UnlockVault() error = %v", err) + } + if lifecycle.lastUnlockKey.Password != lifecycle.unlockPassword { + t.Fatalf("UnlockVault() password = %q, want %q", lifecycle.lastUnlockKey.Password, lifecycle.unlockPassword) + } + if !bytes.Equal(lifecycle.lastUnlockKey.KeyFileData, lifecycle.unlockKeyFile) { + t.Fatalf("UnlockVault() key data = %q, want %q", lifecycle.lastUnlockKey.KeyFileData, lifecycle.unlockKeyFile) + } + + listed, err := client.ListEntries(ctx, &keepassgov1.ListEntriesRequest{Path: []string{"Root", "Internet"}}) + if err != nil { + t.Fatalf("ListEntries() after unlock error = %v", err) + } + if len(listed.Entries) != 1 || listed.Entries[0].Title != "Remote Git" { + t.Fatalf("ListEntries().Entries = %#v, want Remote Git after unlock", listed.Entries) + } +} + func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) { t.Parallel() @@ -131,6 +222,143 @@ func TestVaultServiceOpensAndSavesVaultThroughLifecycleBackend(t *testing.T) { } } +func TestVaultServiceLifecycleMethodsRequireLifecycleBackend(t *testing.T) { + t.Parallel() + + client, _, cleanup := newTestClient(t) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + + testCases := []struct { + name string + call func() error + }{ + { + name: "open", + call: func() error { + _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{Path: "/tmp/test.kdbx"}) + return err + }, + }, + { + name: "open_remote", + call: func() error { + _, err := client.OpenRemoteVault(ctx, &keepassgov1.OpenRemoteVaultRequest{ + BaseUrl: "https://dav.example.com", + Path: "vaults/main.kdbx", + }) + return err + }, + }, + { + name: "save", + call: func() error { + _, err := client.SaveVault(ctx, &keepassgov1.SaveVaultRequest{}) + return err + }, + }, + { + name: "lock", + call: func() error { + _, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{}) + return err + }, + }, + { + name: "unlock", + call: func() error { + _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{}) + return err + }, + }, + } + + for _, tt := range testCases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + err := tt.call() + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("%s code = %v, want %v", tt.name, status.Code(err), codes.FailedPrecondition) + } + }) + } +} + +func TestVaultServiceLifecycleMethodsMapBackendErrors(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + call func(keepassgov1.VaultServiceClient, context.Context) error + err error + want codes.Code + }{ + { + name: "open not found", + call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error { + _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{Path: "/tmp/missing.kdbx"}) + return err + }, + err: os.ErrNotExist, + want: codes.NotFound, + }, + { + name: "open invalid master key", + call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error { + _, err := client.OpenVault(ctx, &keepassgov1.OpenVaultRequest{Path: "/tmp/test.kdbx"}) + return err + }, + err: vault.ErrInvalidMasterKey, + want: codes.InvalidArgument, + }, + { + name: "save no path", + call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error { + _, err := client.SaveVault(ctx, &keepassgov1.SaveVaultRequest{}) + return err + }, + err: session.ErrNoPath, + want: codes.FailedPrecondition, + }, + { + name: "lock already locked", + call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error { + _, err := client.LockVault(ctx, &keepassgov1.LockVaultRequest{}) + return err + }, + err: session.ErrLocked, + want: codes.FailedPrecondition, + }, + { + name: "unlock invalid master key", + call: func(client keepassgov1.VaultServiceClient, ctx context.Context) error { + _, err := client.UnlockVault(ctx, &keepassgov1.UnlockVaultRequest{Password: "wrong"}) + return err + }, + err: vault.ErrInvalidMasterKey, + want: codes.InvalidArgument, + }, + } + + for _, tt := range testCases { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + lifecycle := &stubLifecycle{err: tt.err} + client, _, cleanup := newTestClientWithLifecycle(t, lifecycle) + defer cleanup() + + ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer test-token") + err := tt.call(client, ctx) + if status.Code(err) != tt.want { + t.Fatalf("%s code = %v, want %v", tt.name, status.Code(err), tt.want) + } + }) + } +} + func TestVaultServiceListsEntriesForAuthorizedClients(t *testing.T) { t.Parallel() @@ -503,7 +731,7 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa Path: []string{"Root", "Internet"}, }, }, - Path: []string{"Root", "Internet"}, + Path: []string{"Root", "Internet"}, }, { ID: "surveillance-console", @@ -560,7 +788,7 @@ func newTestClientWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepass server := grpc.NewServer(grpc.UnaryInterceptor(BearerTokenInterceptor("test-token"))) clipboardWriter := &memoryClipboardWriter{} keepassgov1.RegisterVaultServiceServer(server, NewServerWithLifecycle( - vault.Model{}, + lifecycle.model, passwords.DefaultProfiles(), clipboardWriter, lifecycle, @@ -598,12 +826,16 @@ func (w *memoryClipboardWriter) WriteText(text string) error { } type stubLifecycle struct { - model vault.Model - openPath string - remoteBaseURL string - remotePath string - saved bool - locked bool + model vault.Model + openPath string + remoteBaseURL string + remotePath string + saved bool + locked bool + err error + unlockPassword string + unlockKeyFile []byte + lastUnlockKey vault.MasterKey } func (s *stubLifecycle) Current() (vault.Model, error) { @@ -614,17 +846,56 @@ func (s *stubLifecycle) Current() (vault.Model, error) { } func (s *stubLifecycle) Open(path string, _ vault.MasterKey) error { + if s.err != nil { + return s.err + } s.openPath = path + s.locked = false return nil } func (s *stubLifecycle) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error { + if s.err != nil { + return s.err + } s.remoteBaseURL = client.BaseURL s.remotePath = path + s.locked = false return nil } func (s *stubLifecycle) Save() error { + if s.err != nil { + return s.err + } s.saved = true return nil } + +func (s *stubLifecycle) Lock() error { + if s.err != nil { + return s.err + } + + s.locked = true + return nil +} + +func (s *stubLifecycle) Unlock(key vault.MasterKey) error { + if s.err != nil { + return s.err + } + if s.unlockPassword != "" && key.Password != s.unlockPassword { + return vault.ErrInvalidMasterKey + } + if s.unlockKeyFile != nil && !bytes.Equal(key.KeyFileData, s.unlockKeyFile) { + return vault.ErrInvalidMasterKey + } + + s.lastUnlockKey = vault.MasterKey{ + Password: key.Password, + KeyFileData: append([]byte(nil), key.KeyFileData...), + } + s.locked = false + return nil +} diff --git a/proto/keepassgo/v1/keepassgo.pb.go b/proto/keepassgo/v1/keepassgo.pb.go index 7136e87..8708e19 100644 --- a/proto/keepassgo/v1/keepassgo.pb.go +++ b/proto/keepassgo/v1/keepassgo.pb.go @@ -479,6 +479,8 @@ func (*LockVaultResponse) Descriptor() ([]byte, []int) { type UnlockVaultRequest struct { state protoimpl.MessageState `protogen:"open.v1"` + Password string `protobuf:"bytes,1,opt,name=password,proto3" json:"password,omitempty"` + KeyFileData []byte `protobuf:"bytes,2,opt,name=key_file_data,json=keyFileData,proto3" json:"key_file_data,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -513,6 +515,20 @@ func (*UnlockVaultRequest) Descriptor() ([]byte, []int) { return file_proto_keepassgo_v1_keepassgo_proto_rawDescGZIP(), []int{10} } +func (x *UnlockVaultRequest) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +func (x *UnlockVaultRequest) GetKeyFileData() []byte { + if x != nil { + return x.KeyFileData + } + return nil +} + type UnlockVaultResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -2364,8 +2380,10 @@ const file_proto_keepassgo_v1_keepassgo_proto_rawDesc = "" + "\x10SaveVaultRequest\"\x13\n" + "\x11SaveVaultResponse\"\x12\n" + "\x10LockVaultRequest\"\x13\n" + - "\x11LockVaultResponse\"\x14\n" + - "\x12UnlockVaultRequest\"\x15\n" + + "\x11LockVaultResponse\"T\n" + + "\x12UnlockVaultRequest\x12\x1a\n" + + "\bpassword\x18\x01 \x01(\tR\bpassword\x12\"\n" + + "\rkey_file_data\x18\x02 \x01(\fR\vkeyFileData\"\x15\n" + "\x13UnlockVaultResponse\">\n" + "\x12ListEntriesRequest\x12\x12\n" + "\x04path\x18\x01 \x03(\tR\x04path\x12\x14\n" + diff --git a/proto/keepassgo/v1/keepassgo.proto b/proto/keepassgo/v1/keepassgo.proto index 4005184..f8d1583 100644 --- a/proto/keepassgo/v1/keepassgo.proto +++ b/proto/keepassgo/v1/keepassgo.proto @@ -67,7 +67,10 @@ message LockVaultRequest {} message LockVaultResponse {} -message UnlockVaultRequest {} +message UnlockVaultRequest { + string password = 1; + bytes key_file_data = 2; +} message UnlockVaultResponse {} From 43d253aa2101f05bf29ba0b2cd4f4f357713c73f Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:19:54 -0700 Subject: [PATCH 11/13] Add Segment 6 entry editor coverage --- appstate/state_test.go | 33 +++++++++++++++ main_test.go | 92 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/appstate/state_test.go b/appstate/state_test.go index d8287e4..e4b194d 100644 --- a/appstate/state_test.go +++ b/appstate/state_test.go @@ -609,6 +609,39 @@ func TestDuplicateSelectedEntryCreatesCopyAndSelectsIt(t *testing.T) { } } +func TestMoveSelectedEntryMovesEntryToNewPathAndMarksDirty(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{model: vault.Model{ + Entries: []vault.Entry{ + {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, + }, + }} + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + SelectedEntryID: "vault-console", + } + + if err := state.MoveSelectedEntry([]string{"Root", "Infrastructure"}); err != nil { + t.Fatalf("MoveSelectedEntry() error = %v", err) + } + + oldPath := sess.model.EntriesInPath([]string{"Root", "Internet"}) + if len(oldPath) != 0 { + t.Fatalf("EntriesInPath(Root/Internet) = %#v, want empty after move", oldPath) + } + + newPath := sess.model.EntriesInPath([]string{"Root", "Infrastructure"}) + if len(newPath) != 1 || newPath[0].ID != "vault-console" { + t.Fatalf("EntriesInPath(Root/Infrastructure) = %#v, want moved vault-console entry", newPath) + } + + if !state.Dirty { + t.Fatal("Dirty = false, want true after move") + } +} + func TestRestoreSelectedEntryVersionReplacesCurrentVersion(t *testing.T) { t.Parallel() diff --git a/main_test.go b/main_test.go index 99bdbce..b97ce50 100644 --- a/main_test.go +++ b/main_test.go @@ -677,6 +677,98 @@ func TestUISavesDuplicatesDeletesAndRestoresEntriesFromTheEditor(t *testing.T) { } } +func TestUICreatesEntryWithAllSupportedEditorFields(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + + u.entryID.SetText("bellagio") + u.entryTitle.SetText("Bellagio") + u.entryUsername.SetText("rustyryan") + u.entryPassword.SetText("token-1") + u.entryURL.SetText("https://bellagio.example.invalid") + u.entryNotes.SetText("Registrar account") + u.entryTags.SetText("dns, registrar") + u.entryPath.SetText("Root / Internet") + u.entryFields.SetText("Environment=prod\nAccount ID=12345") + + if err := u.saveEntryAction(); err != nil { + t.Fatalf("saveEntryAction() create error = %v", err) + } + + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) { + t.Fatalf("filteredTitles() = %v, want [Bellagio]", got) + } + + item, ok := u.selectedEntry() + if !ok { + t.Fatal("selectedEntry() ok = false, want created entry") + } + if item.Title != "Bellagio" || item.Username != "rustyryan" || item.Password != "token-1" || item.URL != "https://bellagio.example.invalid" { + t.Fatalf("selectedEntry() = %#v, want created Bellagio credentials", item) + } + if item.Notes != "Registrar account" { + t.Fatalf("selectedEntry().Notes = %q, want %q", item.Notes, "Registrar account") + } + if !slices.Equal(item.Tags, []string{"dns", "registrar"}) { + t.Fatalf("selectedEntry().Tags = %v, want [dns registrar]", item.Tags) + } + if item.Fields["Environment"] != "prod" || item.Fields["Account ID"] != "12345" { + t.Fatalf("selectedEntry().Fields = %#v, want parsed custom fields", item.Fields) + } +} + +func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.filter() + u.state.SelectedEntryID = "vault-console" + u.loadSelectedEntryIntoEditor() + u.entryPath.SetText("Root / Infrastructure") + + if err := u.saveEntryAction(); err != nil { + t.Fatalf("saveEntryAction() move error = %v", err) + } + + u.currentPath = []string{"Root", "Internet"} + u.filter() + if got := u.filteredTitles(); len(got) != 0 { + t.Fatalf("filteredTitles() in old path = %v, want empty after move", got) + } + + u.currentPath = []string{"Root", "Infrastructure"} + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) { + t.Fatalf("filteredTitles() in new path = %v, want [Vault Console]", got) + } + + item, ok := u.selectedEntry() + if !ok { + t.Fatal("selectedEntry() ok = false, want moved entry") + } + if !slices.Equal(item.Path, []string{"Root", "Infrastructure"}) { + t.Fatalf("selectedEntry().Path = %v, want [Root Infrastructure]", item.Path) + } +} + func TestUITemplateAndAttachmentActionsWorkThroughEditor(t *testing.T) { t.Parallel() From c5e2df4ca7ece4677e70605f22570823f7c30a1c Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:26:14 -0700 Subject: [PATCH 12/13] Add regression coverage for KDBX reopen cycles --- session/session_test.go | 129 ++++++++++++++++++++++++++++++++++++++++ vault/kdbx.go | 126 ++++++++++++++++++++++++++++----------- vault/kdbx_test.go | 109 +++++++++++++++++++++++++++++++++ 3 files changed, 330 insertions(+), 34 deletions(-) diff --git a/session/session_test.go b/session/session_test.go index 7324da5..748c009 100644 --- a/session/session_test.go +++ b/session/session_test.go @@ -537,3 +537,132 @@ func TestSavePreservesOpenedKDBXSecuritySettings(t *testing.T) { t.Fatalf("saved KDF UUID = %x, want %x", reloaded.Header.FileHeaders.KdfParameters.UUID, db.Header.FileHeaders.KdfParameters.UUID) } } + +func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + model := vault.Model{ + Entries: []vault.Entry{ + { + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-2", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + Attachments: map[string][]byte{ + "token.txt": []byte("secret attachment contents"), + }, + History: []vault.Entry{ + { + ID: "entry-1-history-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + }, + }, + Templates: []vault.Entry{ + { + ID: "tpl-1", + Title: "Website Login", + Username: "template-user", + Password: "template-password", + Path: []string{"Templates", "Web"}, + }, + }, + RecycleBin: []vault.Entry{ + { + ID: "deleted-1", + Title: "Retired Entry", + Username: "archived-user", + Password: "retired-token", + Path: []string{"Root", "Archive"}, + }, + }, + Groups: [][]string{ + {"Root", "Archive"}, + {"Root", "Empty Group"}, + {"Templates", "Web"}, + }, + } + + var remoteBytes bytes.Buffer + if err := vault.SaveKDBXWithKey(&remoteBytes, model, key); err != nil { + t.Fatalf("SaveKDBXWithKey(seed remote) error = %v", err) + } + + etag := "\"v1\"" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("ETag", etag) + _, _ = w.Write(remoteBytes.Bytes()) + case http.MethodPut: + if got := r.Header.Get("If-Match"); got != etag { + t.Fatalf("If-Match header = %q, want %q", got, etag) + } + + payload, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll(PUT body) error = %v", err) + } + + remoteBytes.Reset() + if _, err := remoteBytes.Write(payload); err != nil { + t.Fatalf("Write(remoteBytes) error = %v", err) + } + + etag = "\"v2\"" + w.Header().Set("ETag", etag) + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer server.Close() + + client := webdav.Client{BaseURL: server.URL} + + var sess Manager + if err := sess.OpenRemote(client, "vaults/main.kdbx", key); err != nil { + t.Fatalf("OpenRemote() error = %v", err) + } + if err := sess.SaveRemote(); err != nil { + t.Fatalf("SaveRemote() error = %v", err) + } + + var reopened Manager + if err := reopened.OpenRemote(client, "vaults/main.kdbx", key); err != nil { + t.Fatalf("reopen OpenRemote() error = %v", err) + } + + current, err := reopened.Current() + if err != nil { + t.Fatalf("Current() after reopen error = %v", err) + } + + got := current.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 1 { + t.Fatalf("len(EntriesInPath(Root/Internet)) after reopen = %d, want 1", len(got)) + } + if got[0].ID != "entry-1" { + t.Fatalf("entry ID after remote reopen = %q, want %q", got[0].ID, "entry-1") + } + if len(got[0].History) != 1 || got[0].History[0].ID != "entry-1-history-1" { + t.Fatalf("History after remote reopen = %#v, want stable history ID entry-1-history-1", got[0].History) + } + if string(got[0].Attachments["token.txt"]) != "secret attachment contents" { + t.Fatalf("attachment after remote reopen = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents") + } + if len(current.Templates) != 1 || current.Templates[0].Path[1] != "Web" { + t.Fatalf("Templates after remote reopen = %#v, want Website Login in Templates/Web", current.Templates) + } + if len(current.RecycleBin) != 1 || current.RecycleBin[0].Path[1] != "Archive" { + t.Fatalf("RecycleBin after remote reopen = %#v, want retired entry in Root/Archive", current.RecycleBin) + } +} diff --git a/vault/kdbx.go b/vault/kdbx.go index 93f4ab9..b8ffd14 100644 --- a/vault/kdbx.go +++ b/vault/kdbx.go @@ -23,8 +23,8 @@ type KDBXConfig struct { var ErrInvalidMasterKey = errors.New("invalid master key") const ( - templatesRoot = "Templates" - recycleBinRoot = "Recycle Bin" + templatesRoot = "Templates" + recycleBinRoot = "Recycle Bin" keepassGOIDField = "KeePassGO-ID" ) @@ -46,33 +46,29 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config * return err } - header := gokeepasslib.NewHeader() + db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4()) + db.Credentials = credentials + db.Content.Meta = gokeepasslib.NewMetaData() + db.Content.Root = &gokeepasslib.RootData{} if config != nil && config.Header != nil { - header = cloneHeader(config.Header) + db.Header = cloneHeader(config.Header) + db.Hashes = gokeepasslib.NewHashes(db.Header) } - content := &gokeepasslib.DBContent{ - Meta: gokeepasslib.NewMetaData(), - Root: &gokeepasslib.RootData{}, - } - if header.IsKdbx4() { + if db.Header.IsKdbx4() { if config != nil && config.InnerHeader != nil { - content.InnerHeader = cloneInnerHeader(config.InnerHeader) - } else { - content.InnerHeader = &gokeepasslib.InnerHeader{ + db.Content.InnerHeader = cloneInnerHeader(config.InnerHeader) + db.Content.InnerHeader.Binaries = nil + } else if db.Content.InnerHeader == nil { + db.Content.InnerHeader = &gokeepasslib.InnerHeader{ InnerRandomStreamID: gokeepasslib.ChaChaStreamID, InnerRandomStreamKey: randomBytes(64), } } + } else { + db.Content.InnerHeader = nil } - - db := &gokeepasslib.Database{ - Header: header, - Credentials: credentials, - Content: content, - Hashes: gokeepasslib.NewHashes(header), - } - db.Content.Root.Groups = buildGroupTree(db, entriesForPersistence(model)) - db.Content.Root.DeletedObjects = marshalDeletedObjects(model.RecycleBin) + db.Content.Root.Groups = buildGroupTree(db, model) + db.Content.Root.DeletedObjects = nil if err := db.LockProtectedEntries(); err != nil { return fmt.Errorf("lock protected entries: %w", err) @@ -87,20 +83,21 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config * func appendGroupEntries(model *Model, db *gokeepasslib.Database, group gokeepasslib.Group, path []string) { path = append(clonePath(path), group.Name) + model.CreateGroup(path[:len(path)-1], group.Name) for _, entry := range group.Entries { appendModelEntry(model, Entry{ - ID: extractEntryID(entry), - Title: entry.GetTitle(), - Username: entry.GetContent("UserName"), - Password: entry.GetPassword(), - URL: entry.GetContent("URL"), - Notes: entry.GetContent("Notes"), - Tags: splitTags(entry.Tags), - Fields: extractCustomFields(entry), + ID: extractEntryID(entry), + Title: entry.GetTitle(), + Username: entry.GetContent("UserName"), + Password: entry.GetPassword(), + URL: entry.GetContent("URL"), + Notes: entry.GetContent("Notes"), + Tags: splitTags(entry.Tags), + Fields: extractCustomFields(entry), Attachments: extractAttachments(db, entry), - History: extractHistory(db, entry, path), - Path: clonePath(path), + History: extractHistory(db, entry, path), + Path: clonePath(path), }) } @@ -207,7 +204,7 @@ func extractHistory(db *gokeepasslib.Database, entry gokeepasslib.Entry, path [] for _, item := range entry.Histories { for _, historical := range item.Entries { history = append(history, Entry{ - ID: marshalUUID(historical.UUID), + ID: extractEntryID(historical), Title: historical.GetTitle(), Username: historical.GetContent("UserName"), Password: historical.GetPassword(), @@ -235,7 +232,8 @@ type MasterKey struct { KeyFileData []byte } -func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Group { +func buildGroupTree(db *gokeepasslib.Database, model Model) []gokeepasslib.Group { + entries := entriesForPersistence(model) root := &groupNode{children: map[string]*groupNode{}} for _, entry := range entries { node := root @@ -250,6 +248,18 @@ func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.G } node.entries = append(node.entries, entry) } + for _, path := range groupPathsForPersistence(model, entries) { + node := root + for _, segment := range path { + if node.children[segment] == nil { + node.children[segment] = &groupNode{ + name: segment, + children: map[string]*groupNode{}, + } + } + node = node.children[segment] + } + } groups := marshalGroups(db, root) if len(groups) > 0 { @@ -261,6 +271,31 @@ func buildGroupTree(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.G return []gokeepasslib.Group{group} } +func groupPathsForPersistence(model Model, entries []Entry) [][]string { + seen := map[string]bool{} + var groups [][]string + appendPath := func(path []string) { + key := strings.Join(path, "\x00") + if seen[key] { + return + } + seen[key] = true + groups = append(groups, slices.Clone(path)) + } + + for _, entry := range entries { + for i := 1; i <= len(entry.Path); i++ { + appendPath(entry.Path[:i]) + } + } + for _, path := range model.Groups { + for i := 1; i <= len(path); i++ { + appendPath(path[:i]) + } + } + return groups +} + func LoadKDBXWithKey(r io.Reader, key MasterKey) (Model, error) { model, _, err := LoadKDBXWithConfig(r, key) return model, err @@ -407,7 +442,7 @@ func isInvalidCredentialError(err error) bool { func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Group { names := slices.Collect(maps.Keys(node.children)) - slices.Sort(names) + slices.SortFunc(names, compareGroupNames) var groups []gokeepasslib.Group for _, name := range names { @@ -422,6 +457,29 @@ func marshalGroups(db *gokeepasslib.Database, node *groupNode) []gokeepasslib.Gr return groups } +func compareGroupNames(a, b string) int { + switch { + case a == b: + return 0 + case a == "Root": + return -1 + case b == "Root": + return 1 + case a == templatesRoot: + return -1 + case b == templatesRoot: + return 1 + case a == recycleBinRoot: + return 1 + case b == recycleBinRoot: + return -1 + case a < b: + return -1 + default: + return 1 + } +} + func marshalEntries(db *gokeepasslib.Database, entries []Entry) []gokeepasslib.Entry { slices.SortFunc(entries, func(a, b Entry) int { switch { diff --git a/vault/kdbx_test.go b/vault/kdbx_test.go index a147b64..f856c72 100644 --- a/vault/kdbx_test.go +++ b/vault/kdbx_test.go @@ -3,6 +3,7 @@ package vault import ( "bytes" "errors" + "slices" "testing" "github.com/tobischo/gokeepasslib/v3" @@ -602,6 +603,114 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) { } } +func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) { + t.Parallel() + + model := Model{ + Entries: []Entry{ + { + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-2", + URL: "https://vault.crew.example.invalid", + Notes: "Current credential", + Path: []string{"Root", "Internet"}, + Attachments: map[string][]byte{ + "token.txt": []byte("secret attachment contents"), + }, + History: []Entry{ + { + ID: "entry-1-history-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Notes: "Original credential", + Path: []string{"Root", "Internet"}, + }, + }, + }, + }, + Templates: []Entry{ + { + ID: "tpl-1", + Title: "Website Login", + Username: "template-user", + Password: "template-password", + Path: []string{"Templates", "Web"}, + }, + }, + RecycleBin: []Entry{ + { + ID: "deleted-1", + Title: "Retired Entry", + Username: "archived-user", + Password: "retired-token", + Path: []string{"Root", "Archive"}, + }, + }, + Groups: [][]string{ + {"Root", "Archive"}, + {"Root", "Empty Group"}, + {"Templates", "Web"}, + }, + } + + var encoded bytes.Buffer + if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil { + t.Fatalf("SaveKDBX(first cycle) error = %v", err) + } + + reopened, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple") + if err != nil { + t.Fatalf("LoadKDBX(first cycle) error = %v", err) + } + + encoded.Reset() + if err := SaveKDBX(&encoded, reopened, "correct horse battery staple"); err != nil { + t.Fatalf("SaveKDBX(second cycle) error = %v", err) + } + + reopened, err = LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple") + if err != nil { + t.Fatalf("LoadKDBX(second cycle) error = %v", err) + } + + got := reopened.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 1 { + t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got)) + } + if got[0].ID != "entry-1" { + t.Fatalf("entry ID after reopen cycles = %q, want %q", got[0].ID, "entry-1") + } + if len(got[0].History) != 1 { + t.Fatalf("len(History) after reopen cycles = %d, want 1", len(got[0].History)) + } + if got[0].History[0].ID != "entry-1-history-1" { + t.Fatalf("history ID after reopen cycles = %q, want %q", got[0].History[0].ID, "entry-1-history-1") + } + if string(got[0].Attachments["token.txt"]) != "secret attachment contents" { + t.Fatalf("attachment after reopen cycles = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents") + } + + if len(reopened.Templates) != 1 || reopened.Templates[0].Path[1] != "Web" { + t.Fatalf("Templates after reopen cycles = %#v, want Website Login in Templates/Web", reopened.Templates) + } + if len(reopened.RecycleBin) != 1 || reopened.RecycleBin[0].Path[1] != "Archive" { + t.Fatalf("RecycleBin after reopen cycles = %#v, want recycled entry in Root/Archive", reopened.RecycleBin) + } + + rootGroups := reopened.ChildGroups([]string{"Root"}) + if !slices.Equal(rootGroups, []string{"Archive", "Empty Group", "Internet"}) { + t.Fatalf("ChildGroups(Root) after reopen cycles = %v, want [Archive Empty Group Internet]", rootGroups) + } + templateGroups := reopened.ChildGroups([]string{"Templates"}) + if !slices.Equal(templateGroups, []string{"Web"}) { + t.Fatalf("ChildGroups(Templates) after reopen cycles = %v, want [Web]", templateGroups) + } +} + func mustGroup(name string, children ...any) gokeepasslib.Group { group := gokeepasslib.NewGroup() group.Name = name From a14101e4155f0343cfbbef60fc0b4ce9df9e3594 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:26:49 -0700 Subject: [PATCH 13/13] Remove dead deleted-object helper --- vault/kdbx.go | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/vault/kdbx.go b/vault/kdbx.go index b8ffd14..ff63bda 100644 --- a/vault/kdbx.go +++ b/vault/kdbx.go @@ -535,23 +535,6 @@ func marshalEntry(db *gokeepasslib.Database, entry Entry) gokeepasslib.Entry { return item } -func marshalDeletedObjects(entries []Entry) []gokeepasslib.DeletedObjectData { - if len(entries) == 0 { - return nil - } - - deletionTime := w.Now() - out := make([]gokeepasslib.DeletedObjectData, 0, len(entries)) - for _, entry := range entries { - out = append(out, gokeepasslib.DeletedObjectData{ - UUID: uuidForEntryID(entry.ID), - DeletionTime: &deletionTime, - }) - } - - return out -} - func uuidForEntryID(id string) gokeepasslib.UUID { if id != "" { var uuid gokeepasslib.UUID