Files
keeporg/internal/kdbx/merge_test.go
T
root 4d2f0b047b Add KDBX merge with KeePass synchronization semantics
Why: picking a winner is what a sync server does when it cannot read the
file. Once it can, the right answer is the union of both edits.

What: kdbx.Merge folds one database into another. Objects are matched by
UUID; the most recently modified version of each wins and the loser is filed
in the entry's history, so nothing is dropped. Deletions apply only when the
deletion is newer than the other side's edit, otherwise the edit resurrects
the object and the stale record is discarded. Groups keep their nesting,
objects move when the other side moved them later, database settings follow
Meta/SettingsChanged, and custom data and icons are unioned by key.
Attachments are remapped from the incoming pool into the target's, since an
entry that kept its old index would silently gain the wrong file.

Proved: twelve tests over independent additions, both conflict directions
with history, deletion, resurrection, nested groups, relocation, attachment
remapping, idempotence, order independence and settings. The last one
marshals a merged database and has keepassxc-cli decrypt an entry that only
ever existed in the incoming copy.
2026-10-29 17:14:01 +01:00

458 lines
12 KiB
Go

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
}