pwstore: keepassxc LIVE-VM proof + root-group entry-path fix

Proven end-to-end (scan→plan→commit→restore-from-backup) against the real
keepassxc-cli 2.7.6 in the incredigo-sbx sandbox VM. The live run surfaced an
adapter bug the fake CSV masked: KeePassXC's CSV "Group" column is the full path
including the root group name (default root is "Passwords"), but keepassxc-cli
addresses entries relative to root (/GitHub, not /Passwords/GitHub). kpEntryPath
now drops the leading root segment; the unit test that encoded the buggy
/Root/GitHub form is corrected and a nested-group table test added.

Records the proof as data: docs/BROWSER-ROTATION.md promotes keepassxc
MOCK-ONLY -> LIVE-VM (the others stay MOCK-ONLY).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-19 16:57:30 -07:00
parent c2f181e56d
commit 71f1aa4c6f
3 changed files with 46 additions and 11 deletions
+13 -4
View File
@@ -92,15 +92,24 @@ func (k *KeePassXC) Export(ctx context.Context, v *vault.Vault) ([]Account, erro
return accts, nil
}
// kpEntryPath builds the "/Group/Title" path keepassxc-cli edit expects. KeePassXC
// exports the root group as "Root"; entries live under "/Root/<…>" but the CLI also
// accepts the group path as exported, so we join them verbatim.
// kpEntryPath builds the entry path keepassxc-cli edit/show expect. KeePassXC's CSV
// "Group" column is the FULL group path INCLUDING the root group name (e.g. the
// default DB names its root group "Passwords", so a top-level entry exports with
// Group="Passwords"). The CLI, however, addresses entries RELATIVE to the root —
// the root group name is omitted from the path: a root entry is "/GitHub", and a
// nested entry "Passwords/Web/GitHub" is addressed "/Web/GitHub". So we drop the
// first (root) segment of the exported group path. Verified against real
// keepassxc-cli 2.7.6 in the sandbox VM (the buggy "/Root/GitHub" form 404s).
func kpEntryPath(group, title string) string {
group = strings.Trim(group, "/")
if group == "" {
return "/" + title
}
return "/" + group + "/" + title
// Strip the leading root-group segment; keep any remaining nested subgroups.
if _, rest, ok := strings.Cut(group, "/"); ok {
return "/" + rest + "/" + title
}
return "/" + title
}
// UpdatePassword changes one entry's password via `keepassxc-cli edit -p <db> <entry>`.
+19 -2
View File
@@ -90,8 +90,10 @@ func TestKeePassXCExport(t *testing.T) {
t.Fatalf("got %d accounts, want 1", len(accts))
}
a := accts[0]
if a.ID != "/Root/GitHub" {
t.Errorf("entry path = %q, want /Root/GitHub", a.ID)
// The root group name ("Root" here) is dropped — keepassxc-cli addresses entries
// relative to the root, so a top-level entry is "/GitHub" not "/Root/GitHub".
if a.ID != "/GitHub" {
t.Errorf("entry path = %q, want /GitHub", a.ID)
}
if a.Username != "alice@example.com" || a.Site != "github.com" {
t.Errorf("account = %+v", a)
@@ -128,6 +130,21 @@ func TestKeePassXCUpdatePassword(t *testing.T) {
}
}
func TestKPEntryPathDropsRootGroup(t *testing.T) {
cases := []struct{ group, title, want string }{
{"Passwords", "GitHub", "/GitHub"}, // root entry: root name dropped
{"Root", "GitHub", "/GitHub"}, // same, different root name
{"", "GitHub", "/GitHub"}, // no group at all
{"Passwords/Web", "GitHub", "/Web/GitHub"}, // nested: keep subgroup, drop root
{"/Passwords/Web/", "GitHub", "/Web/GitHub"}, // surrounding slashes tolerated
}
for _, c := range cases {
if got := kpEntryPath(c.group, c.title); got != c.want {
t.Errorf("kpEntryPath(%q,%q) = %q, want %q", c.group, c.title, got, c.want)
}
}
}
func TestKeePassXCUnavailableWithoutDB(t *testing.T) {
k := &KeePassXC{Bin: "keepassxc-cli"}
if k.Available() {