diff --git a/session/session.go b/session/session.go index 5c8cf88..aa0963e 100644 --- a/session/session.go +++ b/session/session.go @@ -368,6 +368,9 @@ func mergeEntrySet(base, local, latest []vault.Entry) []vault.Entry { for id, current := range localByID { original, hadBase := baseByID[id] if !hadBase || !entriesEqual(original, current) { + if latestCurrent, latestChanged := latestByID[id]; hadBase && latestChanged && !entriesEqual(original, latestCurrent) && !entriesEqual(latestCurrent, current) { + current = mergeConflictedEntry(current, latestCurrent) + } latestByID[id] = current } } @@ -395,6 +398,24 @@ func mergeEntrySet(base, local, latest []vault.Entry) []vault.Entry { return out } +func mergeConflictedEntry(current, latest vault.Entry) vault.Entry { + displaced := cloneEntry(latest) + if sameEntryVersion(current, displaced) { + return current + } + + mergedHistory := make([]vault.Entry, 0, len(current.History)+1) + mergedHistory = append(mergedHistory, displaced) + for _, item := range current.History { + if sameEntryVersion(item, displaced) { + continue + } + mergedHistory = append(mergedHistory, cloneEntry(item)) + } + current.History = mergedHistory + return current +} + func mapEntries(entries []vault.Entry) map[string]vault.Entry { out := make(map[string]vault.Entry, len(entries)) for _, item := range entries { @@ -429,6 +450,42 @@ func equalAttachments(a, b map[string][]byte) bool { return true } +func cloneEntry(entry vault.Entry) vault.Entry { + entry.Tags = slices.Clone(entry.Tags) + entry.Path = slices.Clone(entry.Path) + entry.History = cloneHistory(entry.History) + if entry.Fields != nil { + fields := make(map[string]string, len(entry.Fields)) + for key, value := range entry.Fields { + fields[key] = value + } + entry.Fields = fields + } + if entry.Attachments != nil { + attachments := make(map[string][]byte, len(entry.Attachments)) + for key, value := range entry.Attachments { + attachments[key] = slices.Clone(value) + } + entry.Attachments = attachments + } + return entry +} + +func cloneHistory(history []vault.Entry) []vault.Entry { + if len(history) == 0 { + return nil + } + out := make([]vault.Entry, len(history)) + for i := range history { + out[i] = cloneEntry(history[i]) + } + return out +} + +func sameEntryVersion(a, b vault.Entry) bool { + return entriesEqual(a, b) +} + func mergeGroups(base, local, latest [][]string) [][]string { set := map[string][]string{} for _, path := range latest { diff --git a/session/session_test.go b/session/session_test.go index 748c009..91692b2 100644 --- a/session/session_test.go +++ b/session/session_test.go @@ -666,3 +666,127 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) { t.Fatalf("RecycleBin after remote reopen = %#v, want retired entry in Root/Archive", current.RecycleBin) } } + +func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + model := vault.Model{ + Entries: []vault.Entry{ + { + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + } + + var remoteBytes bytes.Buffer + if err := vault.SaveKDBXWithKey(&remoteBytes, model, key); err != nil { + t.Fatalf("SaveKDBXWithKey(seed remote) error = %v", err) + } + + etag := "\"v1\"" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("ETag", etag) + _, _ = w.Write(remoteBytes.Bytes()) + case http.MethodPut: + if got := r.Header.Get("If-Match"); got != etag { + t.Fatalf("If-Match header = %q, want %q", got, etag) + } + payload, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll(PUT body) error = %v", err) + } + remoteBytes.Reset() + if _, err := remoteBytes.Write(payload); err != nil { + t.Fatalf("Write(remoteBytes) error = %v", err) + } + switch etag { + case "\"v1\"": + etag = "\"v2\"" + default: + etag = "\"v3\"" + } + w.Header().Set("ETag", etag) + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer server.Close() + + client := webdav.Client{BaseURL: server.URL} + + var first Manager + if err := first.OpenRemote(client, "vaults/main.kdbx", key); err != nil { + t.Fatalf("first OpenRemote() error = %v", err) + } + var second Manager + if err := second.OpenRemote(client, "vaults/main.kdbx", key); err != nil { + t.Fatalf("second OpenRemote() error = %v", err) + } + + firstCurrent, err := first.Current() + if err != nil { + t.Fatalf("first Current() error = %v", err) + } + firstCurrent.UpsertEntry(vault.Entry{ + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "remote-token-2", + URL: "https://vault.crew.example.invalid", + Notes: "updated remotely first", + Path: []string{"Root", "Internet"}, + }) + first.Replace(firstCurrent) + if err := first.SaveRemote(); err != nil { + t.Fatalf("first SaveRemote() error = %v", err) + } + + secondCurrent, err := second.Current() + if err != nil { + t.Fatalf("second Current() error = %v", err) + } + secondCurrent.UpsertEntry(vault.Entry{ + ID: "entry-1", + Title: "Vault Console", + Username: "dannyocean", + Password: "local-token-2", + URL: "https://vault.crew.example.invalid/security/badges", + Path: []string{"Root", "Internet"}, + }) + second.Replace(secondCurrent) + if err := second.Synchronize(); err != nil { + t.Fatalf("second Synchronize() error = %v", err) + } + + var reopened Manager + if err := reopened.OpenRemote(client, "vaults/main.kdbx", key); err != nil { + t.Fatalf("reopened OpenRemote() error = %v", err) + } + current, err := reopened.Current() + if err != nil { + t.Fatalf("reopened Current() error = %v", err) + } + + got := current.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 1 { + t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got)) + } + if got[0].Password != "local-token-2" || got[0].URL != "https://vault.crew.example.invalid/security/badges" { + t.Fatalf("entry after synchronize = %#v, want local winning version", got[0]) + } + if len(got[0].History) == 0 { + t.Fatal("len(History) = 0, want overwritten remote variant preserved") + } + if got[0].History[0].Password != "remote-token-2" || got[0].History[0].Notes != "updated remotely first" { + t.Fatalf("History[0] = %#v, want displaced remote version first", got[0].History[0]) + } +}