package kdbx import ( "sort" "testing" "time" ) var ( t0 = time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) t1 = t0.Add(time.Hour) t2 = t0.Add(2 * time.Hour) t3 = t0.Add(3 * time.Hour) ) // vault builds a database with one entry per given title, all modified at t0. func vault(titles ...string) *Database { db := New("Shared", fastKDF) for _, title := range titles { e := NewEntry(title, "user", "pw-"+title, "", "") setTimes(e, t0, t0) db.RootGroup().Nodes = append(db.RootGroup().Nodes, e) } setTimes(db.RootGroup(), t0, t0) return db } // fork makes an independent copy through the file format, the way two // clients each hold their own copy of the same server version. func fork(t *testing.T, db *Database) *Database { t.Helper() data, err := db.Marshal("pw") if err != nil { t.Fatal(err) } out, err := Open(data, "pw") if err != nil { t.Fatal(err) } return out } func setTimes(n *Node, modified, located time.Time) { times := n.Find("Times") if times == nil { times = newTimes() n.Nodes = append(n.Nodes, times) } times.Set("LastModificationTime", EncodeTime(modified)) times.Set("LocationChanged", EncodeTime(located)) } func entryByTitle(db *Database, title string) *Node { var found *Node walkEntries(db, func(e, _ *Node) { if entryValue(e, "Title") == title { found = e } }) return found } // walkEntries visits live entries only, never history. func walkEntries(db *Database, fn func(entry, parent *Node)) { var visit func(g *Node) visit = func(g *Node) { for _, e := range g.FindAll("Entry") { fn(e, g) } for _, sub := range g.FindAll("Group") { visit(sub) } } if root := db.Root.Find("Root"); root != nil { for _, g := range root.FindAll("Group") { visit(g) } } } func titles(db *Database) []string { out := []string{} walkEntries(db, func(e, _ *Node) { out = append(out, entryValue(e, "Title")) }) sort.Strings(out) return out } func markDeleted(db *Database, uuid string, at time.Time) { root := db.Root.Find("Root") d := root.Find("DeletedObjects") if d == nil { d = &Node{Name: "DeletedObjects"} root.Nodes = append(root.Nodes, d) } d.Nodes = append(d.Nodes, &Node{Name: "DeletedObject", Nodes: []*Node{ {Name: "UUID", Text: uuid}, {Name: "DeletionTime", Text: EncodeTime(at)}, }}) } func removeEntry(db *Database, title string) string { var uuid string walkEntries(db, func(e, parent *Node) { if entryValue(e, "Title") == title { uuid = e.Value("UUID") parent.Remove(e) } }) return uuid } func TestMergeUnionOfIndependentAdditions(t *testing.T) { target := vault("Shared Entry") source := fork(t, target) mine := NewEntry("Mine", "u", "p", "", "") setTimes(mine, t1, t1) target.RootGroup().Nodes = append(target.RootGroup().Nodes, mine) theirs := NewEntry("Theirs", "u", "p", "", "") setTimes(theirs, t1, t1) source.RootGroup().Nodes = append(source.RootGroup().Nodes, theirs) res, err := Merge(target, source) if err != nil { t.Fatal(err) } if res.EntriesAdded != 1 { t.Fatalf("entries added = %d, want 1 (%+v)", res.EntriesAdded, res) } want := []string{"Mine", "Shared Entry", "Theirs"} if got := titles(target); !equal(got, want) { t.Fatalf("titles = %v, want %v", got, want) } } func TestMergeNewerEditWinsAndLoserGoesToHistory(t *testing.T) { target := vault("Router") source := fork(t, target) // Both sides edit the same entry; the source edits it later. te := entryByTitle(target, "Router") setEntryPassword(te, "target-password") setTimes(te, t1, t0) se := entryByTitle(source, "Router") setEntryPassword(se, "source-password") setTimes(se, t2, t0) res, err := Merge(target, source) if err != nil { t.Fatal(err) } if res.EntriesUpdated != 1 { t.Fatalf("entries updated = %d (%+v)", res.EntriesUpdated, res) } merged := entryByTitle(target, "Router") if got := entryValue(merged, "Password"); got != "source-password" { t.Fatalf("password = %q, want the newer source value", got) } // The overwritten version must still be reachable. hist := merged.Path("History").FindAll("Entry") var found bool for _, h := range hist { if entryValue(h, "Password") == "target-password" { found = true } } if !found { t.Fatalf("the overwritten password is not in the history (%d items)", len(hist)) } } func TestMergeKeepsNewerTargetAndFilesTheSourceVersion(t *testing.T) { target := vault("Router") source := fork(t, target) te := entryByTitle(target, "Router") setEntryPassword(te, "target-password") setTimes(te, t2, t0) se := entryByTitle(source, "Router") setEntryPassword(se, "source-password") setTimes(se, t1, t0) if _, err := Merge(target, source); err != nil { t.Fatal(err) } merged := entryByTitle(target, "Router") if got := entryValue(merged, "Password"); got != "target-password" { t.Fatalf("password = %q, want the newer target value", got) } var found bool for _, h := range merged.Path("History").FindAll("Entry") { if entryValue(h, "Password") == "source-password" { found = true } } if !found { t.Fatal("the source's losing version was not filed in the history") } } func TestMergeAppliesADeletionThatIsNewerThanTheEdit(t *testing.T) { target := vault("Router", "Keep") source := fork(t, target) uuid := removeEntry(source, "Router") markDeleted(source, uuid, t2) res, err := Merge(target, source) if err != nil { t.Fatal(err) } if res.Deleted != 1 { t.Fatalf("deleted = %d (%+v)", res.Deleted, res) } if got := titles(target); !equal(got, []string{"Keep"}) { t.Fatalf("titles = %v, want only Keep", got) } // The record survives, so a third client learns about the deletion too. recs := target.Root.Path("Root", "DeletedObjects").FindAll("DeletedObject") if len(recs) != 1 || recs[0].Value("UUID") != uuid { t.Fatalf("deleted-object records = %d", len(recs)) } } func TestMergeResurrectsAnEntryEditedAfterItWasDeleted(t *testing.T) { target := vault("Router") source := fork(t, target) // Someone deleted it, but someone else edited it afterwards. uuid := removeEntry(source, "Router") markDeleted(source, uuid, t1) te := entryByTitle(target, "Router") setEntryPassword(te, "edited-after-deletion") setTimes(te, t2, t0) res, err := Merge(target, source) if err != nil { t.Fatal(err) } if res.Resurrected != 1 || res.Deleted != 0 { t.Fatalf("result = %+v, want one resurrection and no deletion", res) } merged := entryByTitle(target, "Router") if merged == nil || entryValue(merged, "Password") != "edited-after-deletion" { t.Fatal("the later edit did not survive the deletion record") } if recs := target.Root.Path("Root", "DeletedObjects").FindAll("DeletedObject"); len(recs) != 0 { t.Fatalf("a stale deletion record was kept: %d", len(recs)) } } func TestMergeAddsNestedGroupsWithTheirEntries(t *testing.T) { target := vault("Top") source := fork(t, target) outer := NewGroup("Servers") setTimes(outer, t1, t1) inner := NewGroup("Production") setTimes(inner, t1, t1) deep := NewEntry("Database", "root", "deep-pw", "", "") setTimes(deep, t1, t1) inner.Nodes = append(inner.Nodes, deep) outer.Nodes = append(outer.Nodes, inner) source.RootGroup().Nodes = append(source.RootGroup().Nodes, outer) res, err := Merge(target, source) if err != nil { t.Fatal(err) } if res.GroupsAdded != 2 || res.EntriesAdded != 1 { t.Fatalf("result = %+v, want two groups and one entry", res) } servers := target.RootGroup().Find("Group") if servers == nil || servers.Value("Name") != "Servers" { t.Fatal("Servers group missing") } production := servers.Find("Group") if production == nil || production.Value("Name") != "Production" { t.Fatal("nesting was flattened") } if e := production.Find("Entry"); e == nil || entryValue(e, "Password") != "deep-pw" { t.Fatal("the nested entry did not arrive in its own group") } } func TestMergeMovesAnEntryWhenTheSourceMovedItLater(t *testing.T) { target := vault("Router") group := NewGroup("Network") setTimes(group, t0, t0) target.RootGroup().Nodes = append(target.RootGroup().Nodes, group) source := fork(t, target) // In the source the entry now lives in the Network group. se := entryByTitle(source, "Router") var sourceRoot *Node walkEntries(source, func(e, parent *Node) { if e == se { sourceRoot = parent } }) sourceRoot.Remove(se) sourceGroup := source.RootGroup().Find("Group") sourceGroup.Nodes = append(sourceGroup.Nodes, se) setTimes(se, t0, t2) // moved, not edited res, err := Merge(target, source) if err != nil { t.Fatal(err) } if res.Relocated != 1 { t.Fatalf("relocated = %d (%+v)", res.Relocated, res) } if target.RootGroup().Find("Entry") != nil { t.Fatal("the entry is still in the root group") } if e := target.RootGroup().Find("Group").Find("Entry"); e == nil { t.Fatal("the entry did not arrive in the Network group") } } func TestMergeRemapsAttachmentsIntoTheTargetPool(t *testing.T) { target := vault("Has Attachment") target.Binaries = []Binary{{Data: []byte("target-only-file")}} source := fork(t, target) // The source holds two attachments; its entry references the second. source.Binaries = []Binary{{Data: []byte("unrelated")}, {Data: []byte("the-right-file")}} se := entryByTitle(source, "Has Attachment") se.Nodes = append(se.Nodes, &Node{Name: "Binary", Nodes: []*Node{ {Name: "Key", Text: "notes.txt"}, {Name: "Value", Attrs: []Attr{{Name: "Ref", Value: "1"}}}, }}) setTimes(se, t2, t0) if _, err := Merge(target, source); err != nil { t.Fatal(err) } merged := entryByTitle(target, "Has Attachment") ref := merged.Find("Binary").Find("Value").Attr("Ref") if ref != "1" { t.Fatalf("remapped ref = %q, want 1 (appended after the target's own)", ref) } if len(target.Binaries) != 2 || string(target.Binaries[1].Data) != "the-right-file" { t.Fatalf("target pool = %d entries, second = %q", len(target.Binaries), target.Binaries[1].Data) } } func TestMergeIsIdempotent(t *testing.T) { target := vault("A") source := fork(t, target) e := NewEntry("B", "u", "p", "", "") setTimes(e, t1, t1) source.RootGroup().Nodes = append(source.RootGroup().Nodes, e) if _, err := Merge(target, source); err != nil { t.Fatal(err) } res, err := Merge(target, source) if err != nil { t.Fatal(err) } if res.Changed() { t.Fatalf("merging the same source twice changed the target again: %+v", res) } if got := titles(target); !equal(got, []string{"A", "B"}) { t.Fatalf("titles = %v", got) } } func TestMergeReachesTheSameContentInEitherOrder(t *testing.T) { base := vault("Shared") a, b := fork(t, base), fork(t, base) ea := NewEntry("FromA", "u", "p", "", "") setTimes(ea, t1, t1) a.RootGroup().Nodes = append(a.RootGroup().Nodes, ea) eb := NewEntry("FromB", "u", "p", "", "") setTimes(eb, t2, t2) b.RootGroup().Nodes = append(b.RootGroup().Nodes, eb) ab, ba := fork(t, a), fork(t, b) if _, err := Merge(ab, b); err != nil { t.Fatal(err) } if _, err := Merge(ba, a); err != nil { t.Fatal(err) } if !equal(titles(ab), titles(ba)) { t.Fatalf("a<-b = %v but b<-a = %v", titles(ab), titles(ba)) } } func TestMergeTakesNewerDatabaseSettings(t *testing.T) { target := vault("X") target.Root.Find("Meta").Set("SettingsChanged", EncodeTime(t1)) source := fork(t, target) sm := source.Root.Find("Meta") sm.Set("DatabaseName", "Renamed By A Colleague") sm.Set("SettingsChanged", EncodeTime(t3)) if _, err := Merge(target, source); err != nil { t.Fatal(err) } if got := target.Name(); got != "Renamed By A Colleague" { t.Fatalf("database name = %q", got) } } func TestMergedFileStillOpensAndIsReadableByKeePassXC(t *testing.T) { keepassxc(t) target := vault("Shared") source := fork(t, target) e := NewEntry("Added Elsewhere", "u", "merged-pw", "", "") setTimes(e, t1, t1) source.RootGroup().Nodes = append(source.RootGroup().Nodes, e) if _, err := Merge(target, source); err != nil { t.Fatal(err) } path := writeVault(t, target) got := run(t, vaultPassword+"\n", "show", "-q", "-s", "-a", "Password", path, "Added Elsewhere") if trim(got) != "merged-pw" { t.Fatalf("KeePassXC read the merged entry's password as %q", trim(got)) } } func setEntryPassword(entry *Node, password string) { for _, s := range entry.FindAll("String") { if s.Value("Key") == "Password" { s.Find("Value").Text = password return } } } func equal(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func trim(s string) string { for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r' || s[len(s)-1] == ' ') { s = s[:len(s)-1] } return s }