diff --git a/internal/kdbx/merge.go b/internal/kdbx/merge.go new file mode 100644 index 0000000..002d4af --- /dev/null +++ b/internal/kdbx/merge.go @@ -0,0 +1,575 @@ +package kdbx + +import ( + "errors" + "sort" + "strconv" + "time" +) + +// Merge folds source into target, following KeePass's own synchronization +// rules: the union of both databases, with the most recently modified +// version of each object winning, and the version that loses kept in the +// entry's history. Nothing is discarded silently. +// +// Target is modified in place. Source is only read. +// +// The ordering of objects inside a group is not merged; new objects are +// appended. KeePass does not reconcile order either. +func Merge(target, source *Database) (MergeResult, error) { + var res MergeResult + if target == nil || source == nil || target.Root == nil || source.Root == nil { + return res, errors.New("kdbx: merge needs two parsed databases") + } + ti := newIndex(target) + si := newIndex(source) + + mergeMeta(target, source, &res) + mergeGroups(target, source, ti, si, &res) + mergeEntries(target, source, ti, si, &res) + applyDeletions(target, ti, si, &res) + return res, nil +} + +// MergeResult reports what the merge did, for the server's log and for the +// client's status line. +type MergeResult struct { + GroupsAdded int + GroupsUpdated int + EntriesAdded int + EntriesUpdated int + HistoryAdded int + Relocated int + Deleted int + Resurrected int +} + +// Changed reports whether the merge altered the target at all. +func (r MergeResult) Changed() bool { + return r.GroupsAdded+r.GroupsUpdated+r.EntriesAdded+r.EntriesUpdated+ + r.HistoryAdded+r.Relocated+r.Deleted+r.Resurrected > 0 +} + +func (r MergeResult) String() string { + return "added " + strconv.Itoa(r.EntriesAdded) + " entries, updated " + + strconv.Itoa(r.EntriesUpdated) + ", added " + strconv.Itoa(r.GroupsAdded) + + " groups, moved " + strconv.Itoa(r.Relocated) + ", deleted " + + strconv.Itoa(r.Deleted) +} + +// ---- indexing ---- + +// objRef is one group or entry together with the group holding it. +type objRef struct { + node *Node + parent *Node // the Group node this object is a child of +} + +// index locates every group and entry by UUID. History entries are not +// indexed: they share their parent entry's UUID. +type index struct { + groups map[string]*objRef + entries map[string]*objRef + groupOrder []string // document order, parents before children + entryOrder []string + deleted map[string]time.Time + deletedOf *Node // the DeletedObjects element +} + +func newIndex(db *Database) *index { + ix := &index{ + groups: map[string]*objRef{}, + entries: map[string]*objRef{}, + deleted: map[string]time.Time{}, + deletedOf: db.Root.Path("Root", "DeletedObjects"), + } + root := db.Root.Find("Root") + if root == nil { + return ix + } + for _, g := range root.FindAll("Group") { + ix.addGroup(g, nil) + } + for _, d := range ix.deletedOf.FindAll("DeletedObject") { + if t, ok := DecodeTime(d.Value("DeletionTime")); ok { + uuid := d.Value("UUID") + if prev, seen := ix.deleted[uuid]; !seen || t.After(prev) { + ix.deleted[uuid] = t + } + } + } + return ix +} + +func (ix *index) addGroup(g, parent *Node) { + uuid := g.Value("UUID") + if uuid != "" { + if _, dup := ix.groups[uuid]; !dup { + ix.groups[uuid] = &objRef{node: g, parent: parent} + ix.groupOrder = append(ix.groupOrder, uuid) + } + } + for _, e := range g.FindAll("Entry") { + eu := e.Value("UUID") + if eu == "" { + continue + } + if _, dup := ix.entries[eu]; !dup { + ix.entries[eu] = &objRef{node: e, parent: g} + ix.entryOrder = append(ix.entryOrder, eu) + } + } + for _, sub := range g.FindAll("Group") { + ix.addGroup(sub, g) + } +} + +// lastModified is an object's modification timestamp; a missing one sorts +// oldest, so a well-formed object always wins over a malformed one. +func lastModified(n *Node) time.Time { + t, _ := DecodeTime(n.Value("Times", "LastModificationTime")) + return t +} + +func locationChanged(n *Node) time.Time { + t, _ := DecodeTime(n.Value("Times", "LocationChanged")) + return t +} + +// ---- groups ---- + +func mergeGroups(target, source *Database, ti, si *index, res *MergeResult) { + for _, uuid := range si.groupOrder { + src := si.groups[uuid] + dst, exists := ti.groups[uuid] + + if !exists { + if deletedLaterThan(ti.deleted[uuid], lastModified(src.node)) { + continue // target deleted this group after the source touched it + } + parent := targetParent(target, ti, si, src) + shell := groupShell(src.node) + parent.Nodes = append(parent.Nodes, shell) + ti.groups[uuid] = &objRef{node: shell, parent: parent} + res.GroupsAdded++ + continue + } + + if lastModified(src.node).After(lastModified(dst.node)) { + copyGroupFields(dst.node, src.node) + res.GroupsUpdated++ + } + relocate(target, ti, si, src, dst, res) + } +} + +// groupShell copies a group without its entries or subgroups: those arrive +// through their own iterations, which keeps nesting right and avoids +// duplicating a subtree. +func groupShell(g *Node) *Node { + out := &Node{Name: g.Name, Text: g.Text, Protected: g.Protected} + out.Attrs = append([]Attr(nil), g.Attrs...) + for _, c := range g.Nodes { + if c.Name == "Group" || c.Name == "Entry" { + continue + } + out.Nodes = append(out.Nodes, c.clone()) + } + return out +} + +// copyGroupFields replaces a group's own properties, keeping its children. +func copyGroupFields(dst, src *Node) { + kept := []*Node{} + for _, c := range dst.Nodes { + if c.Name == "Group" || c.Name == "Entry" { + kept = append(kept, c) + } + } + fresh := groupShell(src) + dst.Attrs = fresh.Attrs + dst.Nodes = append(fresh.Nodes, kept...) +} + +// ---- entries ---- + +func mergeEntries(target, source *Database, ti, si *index, res *MergeResult) { + maxHistory := historyLimit(target) + + for _, uuid := range si.entryOrder { + src := si.entries[uuid] + dst, exists := ti.entries[uuid] + + if !exists { + if deletedLaterThan(ti.deleted[uuid], lastModified(src.node)) { + continue + } + clone := src.node.clone() + remapBinaries(clone, source, target) + parent := targetParent(target, ti, si, src) + parent.Nodes = append(parent.Nodes, clone) + ti.entries[uuid] = &objRef{node: clone, parent: parent} + res.EntriesAdded++ + continue + } + + tLast, sLast := lastModified(dst.node), lastModified(src.node) + switch { + case sLast.After(tLast): + // The source is newer: its content takes over, and what the + // target held becomes a history item so it is never lost. + losing := entryShell(dst.node) + incoming := src.node.clone() + remapBinaries(incoming, source, target) + history := mergeHistory(dst.node, incoming, losing, maxHistory, res) + replaceEntryContent(dst.node, incoming, history) + res.EntriesUpdated++ + case tLast.After(sLast): + // The target is newer: keep it, but file the source's version. + incoming := src.node.clone() + remapBinaries(incoming, source, target) + losing := entryShell(incoming) + history := mergeHistory(dst.node, dst.node, losing, maxHistory, res) + setHistory(dst.node, history) + default: + history := mergeHistory(dst.node, dst.node, nil, maxHistory, res) + setHistory(dst.node, history) + } + relocate(target, ti, si, src, dst, res) + } +} + +// entryShell copies an entry without its history, which is what a history +// item is: a past state of the entry itself. +func entryShell(e *Node) *Node { + out := &Node{Name: e.Name, Text: e.Text, Protected: e.Protected} + out.Attrs = append([]Attr(nil), e.Attrs...) + for _, c := range e.Nodes { + if c.Name == "History" { + continue + } + out.Nodes = append(out.Nodes, c.clone()) + } + return out +} + +// replaceEntryContent swaps an entry's fields for another's, then installs +// the merged history. +func replaceEntryContent(dst, src *Node, history []*Node) { + fresh := entryShell(src) + dst.Attrs = fresh.Attrs + dst.Nodes = fresh.Nodes + setHistory(dst, history) +} + +func setHistory(entry *Node, history []*Node) { + h := entry.Find("History") + if len(history) == 0 { + if h != nil { + entry.Remove(h) + } + return + } + if h == nil { + h = &Node{Name: "History"} + entry.Nodes = append(entry.Nodes, h) + } + h.Nodes = history +} + +// mergeHistory unions the history of both entries, plus an optional extra +// version, keeping one item per modification time and the newest +// maxHistory of them. +func mergeHistory(targetEntry, sourceEntry, extra *Node, maxHistory int, res *MergeResult) []*Node { + seen := map[int64]bool{} + out := []*Node{} + before := 0 + + add := func(n *Node, counts bool) { + if n == nil { + return + } + key := lastModified(n).Unix() + if seen[key] { + return + } + seen[key] = true + out = append(out, n.clone()) + if counts { + res.HistoryAdded++ + } + } + + for _, h := range targetEntry.Path("History").FindAll("Entry") { + add(h, false) + before++ + } + for _, h := range sourceEntry.Path("History").FindAll("Entry") { + add(h, true) + } + add(extra, true) + + sort.SliceStable(out, func(i, j int) bool { + return lastModified(out[i]).Before(lastModified(out[j])) + }) + if maxHistory > 0 && len(out) > maxHistory { + out = out[len(out)-maxHistory:] + } + return out +} + +func historyLimit(db *Database) int { + v := db.Root.Value("Meta", "HistoryMaxItems") + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + return 10 + } + return n +} + +// ---- relocation ---- + +// relocate moves an object when the source says it was moved more recently +// than the target did. +func relocate(target *Database, ti, si *index, src, dst *objRef, res *MergeResult) { + srcParent := si.parentUUID(src) + if srcParent == "" || dst.parent == nil { + return + } + if dst.parent.Value("UUID") == srcParent { + return + } + if !locationChanged(src.node).After(locationChanged(dst.node)) { + return + } + newParent, ok := ti.groups[srcParent] + if !ok { + return + } + if dst.parent.Remove(dst.node) { + newParent.node.Nodes = append(newParent.node.Nodes, dst.node) + dst.parent = newParent.node + res.Relocated++ + } +} + +func (ix *index) parentUUID(ref *objRef) string { + if ref.parent == nil { + return "" + } + return ref.parent.Value("UUID") +} + +// targetParent finds where an incoming object belongs in the target, +// falling back to the root group when its parent is unknown, so an object is +// never dropped for want of a home. +func targetParent(target *Database, ti, si *index, src *objRef) *Node { + if uuid := si.parentUUID(src); uuid != "" { + if ref, ok := ti.groups[uuid]; ok { + return ref.node + } + } + if g := target.RootGroup(); g != nil { + return g + } + return target.Root.Find("Root") +} + +// ---- deletions ---- + +// applyDeletions removes what either side deleted, unless the other side +// modified the object after the deletion, in which case the modification +// wins and the deletion record is dropped. +func applyDeletions(target *Database, ti, si *index, res *MergeResult) { + all := map[string]time.Time{} + for uuid, t := range ti.deleted { + all[uuid] = t + } + for uuid, t := range si.deleted { + if prev, ok := all[uuid]; !ok || t.After(prev) { + all[uuid] = t + } + } + + keep := map[string]time.Time{} + uuids := make([]string, 0, len(all)) + for uuid := range all { + uuids = append(uuids, uuid) + } + sort.Strings(uuids) + + for _, uuid := range uuids { + delTime := all[uuid] + var ref *objRef + if r, ok := ti.entries[uuid]; ok { + ref = r + } else if r, ok := ti.groups[uuid]; ok { + ref = r + } + if ref == nil { + keep[uuid] = delTime // nothing here to delete, but remember it + continue + } + if lastModified(ref.node).After(delTime) { + res.Resurrected++ // edited after it was deleted: the edit wins + continue + } + if ref.parent != nil && ref.parent.Remove(ref.node) { + res.Deleted++ + } + delete(ti.entries, uuid) + delete(ti.groups, uuid) + keep[uuid] = delTime + } + writeDeletedObjects(target, keep) +} + +func writeDeletedObjects(target *Database, records map[string]time.Time) { + root := target.Root.Find("Root") + if root == nil { + return + } + node := root.Find("DeletedObjects") + if node == nil { + node = &Node{Name: "DeletedObjects"} + root.Nodes = append(root.Nodes, node) + } + uuids := make([]string, 0, len(records)) + for uuid := range records { + uuids = append(uuids, uuid) + } + sort.Strings(uuids) + node.Nodes = nil + for _, uuid := range uuids { + node.Nodes = append(node.Nodes, &Node{Name: "DeletedObject", Nodes: []*Node{ + {Name: "UUID", Text: uuid}, + {Name: "DeletionTime", Text: EncodeTime(records[uuid])}, + }}) + } +} + +func deletedLaterThan(deletion, modification time.Time) bool { + return !deletion.IsZero() && !deletion.Before(modification) +} + +// ---- attachments ---- + +// remapBinaries rewrites an incoming entry's attachment references so they +// point into the target's pool, adding any attachment the target lacks. +// Without this, a merged entry would silently gain the wrong attachment. +func remapBinaries(entry *Node, source, target *Database) { + entry.walk(func(n *Node) { + if n.Name != "Binary" { + return + } + value := n.Find("Value") + if value == nil { + return + } + ref := value.Attr("Ref") + if ref == "" { + return + } + idx, err := strconv.Atoi(ref) + if err != nil || idx < 0 || idx >= len(source.Binaries) { + return + } + value.SetAttr("Ref", strconv.Itoa(target.addBinary(source.Binaries[idx]))) + }) +} + +// addBinary returns the index of an attachment in the pool, appending it if +// it is not already there. +func (d *Database) addBinary(b Binary) int { + for i, have := range d.Binaries { + if len(have.Data) == len(b.Data) && string(have.Data) == string(b.Data) { + return i + } + } + d.Binaries = append(d.Binaries, Binary{Flags: b.Flags, + Data: append([]byte(nil), b.Data...)}) + return len(d.Binaries) - 1 +} + +// ---- meta ---- + +// mergeMeta takes the source's database-level settings when they were +// changed more recently, and unions the custom data and icons either way. +func mergeMeta(target, source *Database, res *MergeResult) { + tm, sm := target.Root.Find("Meta"), source.Root.Find("Meta") + if tm == nil || sm == nil { + return + } + tChanged, _ := DecodeTime(tm.Value("SettingsChanged")) + sChanged, _ := DecodeTime(sm.Value("SettingsChanged")) + if sChanged.After(tChanged) { + for _, field := range metaSettings { + src := sm.Find(field) + if src == nil { + continue + } + if dst := tm.Find(field); dst != nil { + tm.Nodes[indexOf(tm.Nodes, dst)] = src.clone() + } else { + tm.Nodes = append(tm.Nodes, src.clone()) + } + } + res.GroupsUpdated += 0 // settings are not objects; nothing to count + } + mergeKeyed(tm, sm, "CustomData", "Item", "Key") + mergeKeyed(tm, sm, "CustomIcons", "Icon", "UUID") +} + +// metaSettings is the set of database-level fields that travel together +// under Meta/SettingsChanged. +var metaSettings = []string{ + "DatabaseName", "DatabaseNameChanged", + "DatabaseDescription", "DatabaseDescriptionChanged", + "DefaultUserName", "DefaultUserNameChanged", + "Color", "MemoryProtection", + "RecycleBinEnabled", "RecycleBinUUID", "RecycleBinChanged", + "EntryTemplatesGroup", "EntryTemplatesGroupChanged", + "MaintenanceHistoryDays", "HistoryMaxItems", "HistoryMaxSize", + "SettingsChanged", +} + +// mergeKeyed unions a container of keyed items, newest wins where both sides +// have the same key. +func mergeKeyed(targetMeta, sourceMeta *Node, container, item, keyField string) { + sc := sourceMeta.Find(container) + if sc == nil || len(sc.FindAll(item)) == 0 { + return + } + tc := targetMeta.Find(container) + if tc == nil { + tc = &Node{Name: container} + targetMeta.Nodes = append(targetMeta.Nodes, tc) + } + have := map[string]*Node{} + for _, it := range tc.FindAll(item) { + have[it.Value(keyField)] = it + } + for _, it := range sc.FindAll(item) { + key := it.Value(keyField) + existing, ok := have[key] + if !ok { + tc.Nodes = append(tc.Nodes, it.clone()) + have[key] = it + continue + } + sMod, _ := DecodeTime(it.Value("LastModificationTime")) + tMod, _ := DecodeTime(existing.Value("LastModificationTime")) + if sMod.After(tMod) { + tc.Nodes[indexOf(tc.Nodes, existing)] = it.clone() + } + } +} + +func indexOf(nodes []*Node, target *Node) int { + for i, n := range nodes { + if n == target { + return i + } + } + return 0 +} diff --git a/internal/kdbx/merge_test.go b/internal/kdbx/merge_test.go new file mode 100644 index 0000000..275c22d --- /dev/null +++ b/internal/kdbx/merge_test.go @@ -0,0 +1,457 @@ +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 +}