5fb4f8745b61

H-11: always exchange manifests; per-entry timestamps are diff authority; transitive sync test

time
2026-07-29 14:33 UTC · 2 months ago
machine
koh2
agent
claude-opus-4
session
audit-remediation-2026-07-29
parent
0fee2a175dd2
2 files app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+0 −45app/src/test/java/com/peachpasswords/sync/SyncCoordinatorTest.kt+25 −4
app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+0 −45
@@ -120,30 +120,6 @@120120             warnOnSkew(nowMs, hello.deviceTime)121121             val exchangeDeviceManifest = vault.negotiatesDeviceManifest(hello)122122 123-            if (hello.version == localVersion) {124-                // Entry versions do not cover paired-device introductions or125-                // revocations. Exchange that signed graph when both peers126-                // advertise support; old peers keep the legacy DONE fast path.127-                if (exchangeDeviceManifest) {128-                    session.sendMsg(129-                        SyncMessage.DeviceManifest(vault.buildOutgoingDeviceManifest()),130-                    )131-                    val theirs = session.recvAs<SyncMessage.DeviceManifest>(132-                        "initiator",133-                        "SYNC_DEVICE_MANIFEST",134-                    )135-                    if (theirs.manifest != null) {136-                        vault.applyIncomingDeviceManifest(137-                            theirs.manifest,138-                            session.remoteStaticPublicKey,139-                        )140-                    }141-                }142-                session.sendMsg(SyncMessage.Done(0, 0))143-                session.awaitClosingDone("initiator", "SYNC_DONE")144-                return@withTimeout SyncOutcome(SyncOutcomeStatus.COMPLETED)145-            }146-147123             val ourEntries = vault.buildEntryManifest()148124             val ourTombstones = vault.buildTombstoneManifest()149125             session.sendMsg(SyncMessage.Manifest(ourEntries, ourTombstones))@@ -242,27 +218,6 @@242218             }243219             warnOnSkew(nowMs, hello.deviceTime)244220             val exchangeDeviceManifest = vault.negotiatesDeviceManifest(hello)245-246-            if (hello.version == localVersion) {247-                if (exchangeDeviceManifest) {248-                    val theirs = session.recvAs<SyncMessage.DeviceManifest>(249-                        "responder",250-                        "SYNC_DEVICE_MANIFEST",251-                    )252-                    session.sendMsg(253-                        SyncMessage.DeviceManifest(vault.buildOutgoingDeviceManifest()),254-                    )255-                    if (theirs.manifest != null) {256-                        vault.applyIncomingDeviceManifest(257-                            theirs.manifest,258-                            session.remoteStaticPublicKey,259-                        )260-                    }261-                }262-                session.recvAs<SyncMessage.Done>("responder", "SYNC_DONE")263-                session.sendMsg(SyncMessage.Done(0, 0))264-                return@withTimeout SyncOutcome(SyncOutcomeStatus.COMPLETED)265-            }266221 267222             val ourEntries = vault.buildEntryManifest()268223             val ourTombstones = vault.buildTombstoneManifest()

view file

app/src/test/java/com/peachpasswords/sync/SyncCoordinatorTest.kt+25 −4
@@ -99,7 +99,7 @@9999         assertTrue(messages.contains("sync-stage: role=responder wait=SYNC_DONE"))100100     }101101 102-    @Test fun equal_versions_short_circuit_without_transfer() = runBlocking {102+    @Test fun equal_versions_still_exchange_entry_manifests() = runBlocking {103103         val (sa, sb) = duplex()104104         val a = FakeAdapter("A", version = 7).apply { put("x", 100) }105105         val b = FakeAdapter("B", version = 7).apply { put("y", 200) }@@ -110,9 +110,30 @@110110 111111         assertEquals(SyncOutcomeStatus.COMPLETED, outA.status)112112         assertEquals(SyncOutcomeStatus.COMPLETED, outB.status)113-        assertEquals(0, outA.entriesReceived)114-        assertFalse(a.blobs.containsKey("y"))115-        assertFalse(b.blobs.containsKey("x"))113+        assertEquals(1, outA.entriesReceived)114+        assertTrue(a.blobs.containsKey("y"))115+        assertTrue(b.blobs.containsKey("x"))116+    }117+118+    @Test fun remote_change_transits_through_peer_with_unchanged_version() = runBlocking {119+        val a = FakeAdapter("A", version = 7)120+        val b = FakeAdapter("B", version = 7).apply { put("from-b", 200) }121+        val c = FakeAdapter("C", version = 7)122+123+        suspend fun sync(initiator: FakeAdapter, responder: FakeAdapter) {124+            val (si, sr) = duplex()125+            val ri = async { runSyncInitiator(si, initiator, nowMs = 0) }126+            val rr = async { runSyncResponder(sr, responder, nowMs = 0) }127+            assertEquals(SyncOutcomeStatus.COMPLETED, ri.await().status)128+            assertEquals(SyncOutcomeStatus.COMPLETED, rr.await().status)129+        }130+131+        sync(a, b)132+        assertTrue(a.blobs.containsKey("from-b"))133+        assertEquals(7L, a.version)134+135+        sync(a, c)136+        assertTrue(c.blobs.containsKey("from-b"))116137     }117138 118139     @Test fun equal_versions_still_exchange_device_graphs_when_both_peers_support_it() =

view file