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.
458 lines
12 KiB
Go
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
|
|
}
|