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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user