85d691ffdf4a

feat: inline vault snapshot over pairing channel (v2.9.0)

After ADOPT_APPLIED, the source sends encrypted vault entries + tombstones inline over the same Noise NK channel instead of waiting for a separate sync connection. The receiver applies them immediately and acks with ADOPT_SNAPSHOT_APPLIED. Optional signed device manifest follows. Wire-compatible with peach-browser v4.5.0 (additive snapshot_capable flag; older peers ignore it and fall back to the sync pipeline). - VaultAdopt.kt: 4 new frames, chunking helpers, snapshot_capable negotiation - RelayAdoptionHost.kt: source sends snapshot after grantAdoption (responder) - RelayAdoptionReceiver.kt: source sends snapshot (initiator) + receiver applies - CodePairViewModel/AdoptVaultViewModel: wire new deps via VaultSyncAdapter - 7 snapshot tests (VaultAdoptSnapshotTest) - Fallback preserved: sync pipeline untouched Gate: full testDebugUnitTest BUILD SUCCESSFUL; assembleDebug BUILD SUCCESSFUL.

time
2026-07-30 00:31 UTC · 2 months ago
machine
koh2
parent
933dfea7fdd0
8 files VERSION+1 −1app/build.gradle+1 −1app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+26 −1app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+55 −2app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+147 −0app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+42 −0app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+28 −0app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+129 −0
VERSION+1 −1
@@ -1 +1 @@1-2.8.71+2.9.0

view file

app/build.gradle+1 −1
@@ -32,7 +32,7 @@3232         minSdk 263333         targetSdk 353434         // Keep the Android code in Gradle; the semantic name comes from root VERSION.35-        versionCode 24014835+        versionCode 2401493636         versionName semanticVersion3737 3838         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+26 −1
@@ -11,6 +11,7 @@1111 import kotlinx.coroutines.flow.callbackFlow1212 import kotlinx.coroutines.launch1313 import kotlinx.coroutines.withTimeout14+import kotlinx.coroutines.withTimeoutOrNull1415 import org.json.JSONObject1516 1617 sealed interface RelayAdoptionEvent {@@ -201,8 +202,32 @@201202             override suspend fun upsertPairedDevice(device: PairedDevice) {202203                 sourceDeps.upsertPairedDevice(device)203204             }205+            override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()206+            override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()207+            override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)204208         }205-        return grantAdoption(channel, request, boundDeps)209+        val adopted = grantAdoption(channel, request, boundDeps) ?: return null210+        // If the receiver advertised snapshot support, send the vault inline.211+        if (request.snapshotCapable == true) {212+            try {213+                val snapshot = boundDeps.vaultSnapshot()214+                if (snapshot != null) {215+                    sendVaultSnapshot(channel, snapshot.first, snapshot.second)216+                    val applied = receiveFrame(channel)217+                    if (applied is AdoptionFrame.SnapshotApplied) {218+                        val manifest = boundDeps.outgoingDeviceManifest()219+                        if (manifest != null) {220+                            sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))221+                            withTimeoutOrNull(5_000) { receiveFrame(channel) }222+                        }223+                        boundDeps.markSynced(adopted.deviceId)224+                    }225+                }226+            } catch (_: Exception) {227+                // Fallback: receiver uses the sync pipeline.228+            }229+        }230+        return adopted206231     } finally {207232         noise.close()208233     }

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+55 −2
@@ -3,6 +3,7 @@33 import javax.inject.Inject44 import javax.inject.Singleton55 import kotlinx.coroutines.withTimeout6+import kotlinx.coroutines.withTimeoutOrNull67 import org.json.JSONObject78 89 /**@@ -218,8 +219,36 @@218219             override fun onError(error: String) {219220                 receiverDeps.onError(error)220221             }222+            override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>) {223+                receiverDeps.applySnapshot(entries, tombstones)224+            }225+            override suspend fun markSynced(deviceId: String) {226+                receiverDeps.markSynced(deviceId)227+            }221228         }222-        return requestAdoption(channel, boundDeps)229+        val adopted = requestAdoption(channel, boundDeps) ?: return null230+        // Try to receive the inline vault snapshot.231+        try {232+            val (entries, tombstones) = withTimeout(15_000) {233+                receiveVaultSnapshot(channel)234+            }235+            boundDeps.applySnapshot(entries, tombstones)236+            sendFrame(channel, AdoptionFrame.SnapshotApplied(237+                entriesReceived = entries.size,238+                tombstonesReceived = tombstones.size,239+            ))240+            // Optional device manifest from source241+            try {242+                val manifestFrame = withTimeout(5_000) { receiveFrame(channel) }243+                if (manifestFrame is AdoptionFrame.DeviceManifestFrame) {244+                    sendFrame(channel, AdoptionFrame.DeviceManifestApplied)245+                }246+            } catch (_: Exception) {}247+            boundDeps.markSynced(adopted.deviceId)248+        } catch (_: Exception) {249+            // Fallback: sync pipeline fills the vault.250+        }251+        return adopted223252     } finally {224253         noise.close()225254     }@@ -291,8 +320,32 @@291320             override suspend fun upsertPairedDevice(device: PairedDevice) {292321                 sourceDeps.upsertPairedDevice(device)293322             }323+            override suspend fun vaultSnapshot() = sourceDeps.vaultSnapshot()324+            override suspend fun outgoingDeviceManifest() = sourceDeps.outgoingDeviceManifest()325+            override suspend fun markSynced(deviceId: String) = sourceDeps.markSynced(deviceId)294326         }295-        return grantAdoption(channel, request, boundDeps)327+        val adopted = grantAdoption(channel, request, boundDeps) ?: return null328+        // If the receiver advertised snapshot support, send the vault inline.329+        if (request.snapshotCapable == true) {330+            try {331+                val snapshot = boundDeps.vaultSnapshot()332+                if (snapshot != null) {333+                    sendVaultSnapshot(channel, snapshot.first, snapshot.second)334+                    val applied = receiveFrame(channel)335+                    if (applied is AdoptionFrame.SnapshotApplied) {336+                        val manifest = boundDeps.outgoingDeviceManifest()337+                        if (manifest != null) {338+                            sendFrame(channel, AdoptionFrame.DeviceManifestFrame(manifest))339+                            withTimeoutOrNull(5_000) { receiveFrame(channel) }340+                        }341+                        boundDeps.markSynced(adopted.deviceId)342+                    }343+                }344+            } catch (_: Exception) {345+                // Fallback: receiver uses the sync pipeline.346+            }347+        }348+        return adopted296349     } finally {297350         noise.close()298351     }

view file

app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+147 −0
@@ -2,6 +2,7 @@22 33 import kotlinx.serialization.SerialName44 import kotlinx.serialization.Serializable5+import kotlinx.serialization.encodeToString56 import kotlinx.serialization.json.Json67 78 /**@@ -37,6 +38,7 @@3738         @SerialName("device_name") val deviceName: String,3839         @SerialName("device_type") val deviceType: String,3940         val license: String? = null,41+        @SerialName("snapshot_capable") val snapshotCapable: Boolean? = null,4042     ) : AdoptionFrame()4143 4244     @Serializable@@ -85,7 +87,41 @@8587         val reason: String,8688     ) : AdoptionFrame()8789 90+    /**91+     * One chunk of the inline vault snapshot sent by the source after92+     * ADOPT_APPLIED over the same authenticated Noise NK channel. Entries are93+     * opaque base64 ciphertext envelopes; tombstones ride the final chunk only.94+     */8895     @Serializable96+    @SerialName("ADOPT_SNAPSHOT")97+    data class Snapshot(98+        @SerialName("chunk_index") val chunkIndex: Int,99+        @SerialName("chunk_count") val chunkCount: Int,100+        val entries: Map<String, String>,101+        val tombstones: Map<String, Long>,102+    ) : AdoptionFrame()103+104+    /** Receiver acknowledgement sent after all snapshot chunks are applied. */105+    @Serializable106+    @SerialName("ADOPT_SNAPSHOT_APPLIED")107+    data class SnapshotApplied(108+        @SerialName("entries_received") val entriesReceived: Int,109+        @SerialName("tombstones_received") val tombstonesReceived: Int,110+    ) : AdoptionFrame()111+112+    /** Optional signed device graph appended after the snapshot (manifest v3+). */113+    @Serializable114+    @SerialName("ADOPT_DEVICE_MANIFEST")115+    data class DeviceManifestFrame(116+        val manifest: SignedDeviceManifest,117+    ) : AdoptionFrame()118+119+    /** Receiver acknowledgement for an applied device manifest. */120+    @Serializable121+    @SerialName("ADOPT_DEVICE_MANIFEST_APPLIED")122+    data object DeviceManifestApplied : AdoptionFrame()123+124+    @Serializable89125     data class Source(90126         @SerialName("device_id") val deviceId: String,91127         @SerialName("device_name") val deviceName: String,@@ -102,6 +138,19 @@102138     val relayUrl: String? = null,103139 )104140 141+/**142+ * Wire guardrails for the inline snapshot. A chunk's serialized entries map143+ * stays under [MAX_SNAPSHOT_CHUNK_BYTES]; the whole transfer is capped at144+ * [MAX_SNAPSHOT_CHUNKS] frames so a compromised source cannot exhaust receiver145+ * memory (mirrors SYNC_LIMITS.MAX_FRAME_BYTES in the sync coordinator).146+ */147+const val MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024 // 512 KiB148+const val MAX_SNAPSHOT_CHUNKS = 200149+150+// Headroom added to the per-chunk entries budget when validating an encoded151+// frame on the wire: JSON envelope plus the final chunk's tombstone map.152+private const val SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024153+105154 /** What the RECEIVER (joining device) needs to adopt a vault from a source. */106155 interface AdoptionReceiverDeps {107156     val deviceName: String@@ -126,6 +175,10 @@126175     suspend fun license(): String? = null127176     val pairState: AdoptionPairState? get() = null128177     fun onError(error: String)178+    /** Apply encrypted entries + tombstones from the inline snapshot. */179+    suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>) {}180+    /** Mark the paired device as synced after a successful snapshot. */181+    suspend fun markSynced(deviceId: String) {}129182 }130183 131184 /** What the SOURCE (existing device) needs to grant its vault to a receiver. */@@ -146,6 +199,12 @@146199     suspend fun onConsent(request: AdoptionFrame.Request): Boolean147200     suspend fun upsertPairedDevice(device: PairedDevice)148201     val pairState: AdoptionPairState? get() = null202+    /** Return all encrypted entries + tombstones for the inline snapshot. */203+    suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? = null204+    /** Return the signed device manifest for the graph, if available. */205+    suspend fun outgoingDeviceManifest(): SignedDeviceManifest? = null206+    /** Mark the paired device as synced after a successful snapshot. */207+    suspend fun markSynced(deviceId: String) {}149208 }150209 151210 private val Hex64 = Regex("^[0-9a-f]{64}\$")@@ -184,6 +243,11 @@184243 fun isAdoptReject(frame: AdoptionFrame?): Boolean =185244     frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"186245 246+fun isAdoptSnapshot(frame: AdoptionFrame?): Boolean =247+    frame is AdoptionFrame.Snapshot &&248+        frame.chunkIndex >= 0 &&249+        frame.chunkCount >= 1250+187251 /**188252  * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the189253  * granted root key actually derives the advertised vault id (catching a lying or@@ -204,6 +268,7 @@204268         deviceName = deps.deviceName,205269         deviceType = deps.deviceType.wireValue,206270         license = receiverLicense,271+        snapshotCapable = true,207272     ))208273 209274     val confirmed = deps.confirmSas()@@ -373,6 +438,88 @@373438     runCatching {374439         AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(channel.receive(), Charsets.UTF_8))375440     }.getOrNull()441+442+/**443+ * Source side: chunk the vault snapshot and send it over the pairing channel.444+ * Entries are split so each chunk's serialized entries map stays under445+ * [MAX_SNAPSHOT_CHUNK_BYTES]; tombstones ride the final chunk. If the vault446+ * needs more than [MAX_SNAPSHOT_CHUNKS] chunks, only the first that many are447+ * sent and the fallback sync pipeline fills the remainder.448+ */449+suspend fun sendVaultSnapshot(450+    channel: PairingTransport,451+    entries: Map<String, String>,452+    tombstones: Map<String, Long>,453+) {454+    val chunks = mutableListOf<Map<String, String>>()455+    var current = LinkedHashMap<String, String>()456+    var currentBytes = 2 // "{}"457+    for ((id, envelope) in entries) {458+        // +1 for the separating comma; an overestimate only shrinks chunks.459+        val entryBytes = AdoptJson.encodeToString(mapOf(id to envelope)).length + 1460+        if (current.isNotEmpty() && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {461+            chunks.add(current)462+            current = LinkedHashMap()463+            currentBytes = 2464+        }465+        current[id] = envelope466+        currentBytes += entryBytes467+    }468+    chunks.add(current)469+470+    val chunkCount = minOf(chunks.size, MAX_SNAPSHOT_CHUNKS)471+    for (index in 0 until chunkCount) {472+        val isFinal = index == chunkCount - 1473+        sendFrame(channel, AdoptionFrame.Snapshot(474+            chunkIndex = index,475+            chunkCount = chunkCount,476+            entries = chunks[index],477+            tombstones = if (isFinal) tombstones else emptyMap(),478+        ))479+    }480+}481+482+/**483+ * Receiver side: receive and reassemble every snapshot chunk. Validates the484+ * encoded frame size before parsing, then enforces sequential 0-based485+ * chunk_index ordering, a stable chunk_count within [MAX_SNAPSHOT_CHUNKS], and486+ * structural validity of each frame. Throws on any validation failure so the487+ * caller can fall back to the sync pipeline.488+ */489+suspend fun receiveVaultSnapshot(490+    channel: PairingTransport,491+): Pair<Map<String, String>, Map<String, Long>> {492+    val entries = LinkedHashMap<String, String>()493+    val tombstones = LinkedHashMap<String, Long>()494+    var chunkCount = -1495+    var received = 0496+    while (chunkCount == -1 || received < chunkCount) {497+        val raw = channel.receive()498+        if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {499+            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")500+        }501+        val frame = runCatching {502+            AdoptJson.decodeFromString(AdoptionFrame.serializer(), String(raw, Charsets.UTF_8))503+        }.getOrNull()504+        if (!isAdoptSnapshot(frame)) throw IllegalStateException("ADOPT_SNAPSHOT_INVALID_FRAME")505+        frame as AdoptionFrame.Snapshot506+        if (chunkCount == -1) {507+            chunkCount = frame.chunkCount508+            if (chunkCount > MAX_SNAPSHOT_CHUNKS) {509+                throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS")510+            }511+        } else if (frame.chunkCount != chunkCount) {512+            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH")513+        }514+        if (frame.chunkIndex != received) {515+            throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")516+        }517+        entries.putAll(frame.entries)518+        tombstones.putAll(frame.tombstones)519+        received++520+    }521+    return entries to tombstones522+}376523 377524 private fun isDeviceType(value: String): Boolean =378525     DeviceType.entries.any { it.wireValue == value }

view file

app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+42 −0
@@ -12,6 +12,8 @@1212 import com.peachpasswords.sync.LocalDeviceIdentity1313 import com.peachpasswords.sync.PairedDevice1414 import com.peachpasswords.sync.PairedDeviceStore15+import com.peachpasswords.sync.SignedDeviceManifest16+import com.peachpasswords.sync.VaultSyncAdapter1517 import com.peachpasswords.sync.PeachSyncService1618 import com.peachpasswords.sync.RelayAdoptionReceiver1719 import com.peachpasswords.sync.RelaySettingsStore@@ -311,6 +313,20 @@311313             override fun onError(error: String) {312314                 protocolError = error313315             }316+317+            override suspend fun applySnapshot(entries: Map<String, String>, tombstones: Map<String, Long>) {318+                val adapter = VaultSyncAdapter(319+                    repo = vault,320+                    ourDeviceId = getOrCreateDeviceId(appContext),321+                    pairedDevices = devices,322+                )323+                adapter.applyEntries(entries)324+                adapter.applyTombstones(tombstones)325+            }326+327+            override suspend fun markSynced(deviceId: String) {328+                devices.updateLastSync(deviceId)329+            }314330         }315331 316332     private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =@@ -348,6 +364,32 @@348364             override suspend fun upsertPairedDevice(device: PairedDevice) {349365                 devices.clearRevocation(device.deviceId)350366                 devices.upsert(device)367+            }368+369+            override suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? {370+                val adapter = VaultSyncAdapter(371+                    repo = vault,372+                    ourDeviceId = getOrCreateDeviceId(appContext),373+                    pairedDevices = devices,374+                )375+                val entryManifest = adapter.buildEntryManifest()376+                val entryIds = entryManifest.keys.toList()377+                val entries = if (entryIds.isNotEmpty()) adapter.getEncryptedEntries(entryIds) else emptyMap()378+                val tombstones = adapter.buildTombstoneManifest()379+                return entries to tombstones380+            }381+382+            override suspend fun outgoingDeviceManifest(): SignedDeviceManifest? {383+                val adapter = VaultSyncAdapter(384+                    repo = vault,385+                    ourDeviceId = getOrCreateDeviceId(appContext),386+                    pairedDevices = devices,387+                )388+                return adapter.buildOutgoingDeviceManifest()389+            }390+391+            override suspend fun markSynced(deviceId: String) {392+                devices.updateLastSync(deviceId)351393             }352394         }353395 

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+28 −0
@@ -19,6 +19,8 @@1919 import com.peachpasswords.sync.LocalDeviceIdentity2020 import com.peachpasswords.sync.PairedDevice2121 import com.peachpasswords.sync.PairedDeviceStore22+import com.peachpasswords.sync.SignedDeviceManifest23+import com.peachpasswords.sync.VaultSyncAdapter2224 import com.peachpasswords.sync.buildLocalIdentity2325 import com.peachpasswords.sync.deriveLocalSyncKeypair2426 import com.peachpasswords.sync.getOrCreateDeviceId@@ -194,6 +196,32 @@194196                 // enough to lift a terminal revocation.195197                 deviceStore.clearRevocation(device.deviceId)196198                 deviceStore.upsert(device)199+            }200+201+            override suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? {202+                val adapter = VaultSyncAdapter(203+                    repo = vault,204+                    ourDeviceId = getOrCreateDeviceId(appContext),205+                    pairedDevices = deviceStore,206+                )207+                val entryManifest = adapter.buildEntryManifest()208+                val entryIds = entryManifest.keys.toList()209+                val entries = if (entryIds.isNotEmpty()) adapter.getEncryptedEntries(entryIds) else emptyMap()210+                val tombstones = adapter.buildTombstoneManifest()211+                return entries to tombstones212+            }213+214+            override suspend fun outgoingDeviceManifest(): SignedDeviceManifest? {215+                val adapter = VaultSyncAdapter(216+                    repo = vault,217+                    ourDeviceId = getOrCreateDeviceId(appContext),218+                    pairedDevices = deviceStore,219+                )220+                return adapter.buildOutgoingDeviceManifest()221+            }222+223+            override suspend fun markSynced(deviceId: String) {224+                deviceStore.updateLastSync(deviceId)197225             }198226         }199227 

view file

app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+129 −0
@@ -0,0 +1,129 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.channels.Channel4+import kotlinx.coroutines.runBlocking5+import org.junit.Assert.assertEquals6+import org.junit.Assert.assertTrue7+import org.junit.Test8+9+/**10+ * Inline vault snapshot protocol parity with the browser11+ * `vault-adopt-snapshot.test.ts`. Round-trip, chunking, and validation12+ * tests for the ADOPT_SNAPSHOT / ADOPT_SNAPSHOT_APPLIED frames.13+ */14+class VaultAdoptSnapshotTest {15+16+    private fun memoryPair(): Pair<PairingTransport, PairingTransport> {17+        val a2b = Channel<ByteArray>(Channel.UNLIMITED)18+        val b2a = Channel<ByteArray>(Channel.UNLIMITED)19+        val a = object : PairingTransport {20+            override suspend fun send(msg: ByteArray) { a2b.send(msg) }21+            override suspend fun receive(): ByteArray = b2a.receive()22+            override suspend fun close() {}23+        }24+        val b = object : PairingTransport {25+            override suspend fun send(msg: ByteArray) { b2a.send(msg) }26+            override suspend fun receive(): ByteArray = a2b.receive()27+            override suspend fun close() {}28+        }29+        return a to b30+    }31+32+    @Test33+    fun `round-trips an empty vault`() = runBlocking {34+        val (source, receiver) = memoryPair()35+        sendVaultSnapshot(source, emptyMap(), emptyMap())36+        val (entries, tombstones) = receiveVaultSnapshot(receiver)37+        assertTrue(entries.isEmpty())38+        assertTrue(tombstones.isEmpty())39+    }40+41+    @Test42+    fun `round-trips 50 entries and 5 tombstones in a single chunk`() = runBlocking {43+        val (source, receiver) = memoryPair()44+        val entries = (0 until 50).associate { "id-$it" to "cipher-$it" }45+        val tombstones = (0 until 5).associate { "dead-$it" to (1000L + it) }46+        sendVaultSnapshot(source, entries, tombstones)47+        val (receivedEntries, receivedTombstones) = receiveVaultSnapshot(receiver)48+        assertEquals(entries, receivedEntries)49+        assertEquals(tombstones, receivedTombstones)50+    }51+52+    @Test53+    fun `splits a large vault into 3+ chunks and reassembles`() = runBlocking {54+        val (source, receiver) = memoryPair()55+        var frames = 056+        val countingSource = object : PairingTransport {57+            override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }58+            override suspend fun receive(): ByteArray = source.receive()59+            override suspend fun close() {}60+        }61+        val big = "x".repeat(200 * 1024)62+        val entries = (0 until 5).associate { "id-$it" to big }63+        val tombstones = mapOf("dead-0" to 42L)64+        sendVaultSnapshot(countingSource, entries, tombstones)65+        val (receivedEntries, receivedTombstones) = receiveVaultSnapshot(receiver)66+        assertTrue("expected 3+ chunks, got $frames", frames >= 3)67+        assertEquals(entries, receivedEntries)68+        assertEquals(tombstones, receivedTombstones)69+    }70+71+    @Test72+    fun `rejects out-of-order chunks`() = runBlocking {73+        val (source, receiver) = memoryPair()74+        sendFrame(source, AdoptionFrame.Snapshot(75+            chunkIndex = 1,76+            chunkCount = 2,77+            entries = mapOf("a" to "x"),78+            tombstones = emptyMap(),79+        ))80+        val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()81+        assertTrue(error is IllegalStateException)82+        assertTrue(error!!.message!!.contains("OUT_OF_ORDER"))83+    }84+85+    @Test86+    fun `rejects chunk_count exceeding MAX_SNAPSHOT_CHUNKS`() = runBlocking {87+        val (source, receiver) = memoryPair()88+        sendFrame(source, AdoptionFrame.Snapshot(89+            chunkIndex = 0,90+            chunkCount = MAX_SNAPSHOT_CHUNKS + 1,91+            entries = emptyMap(),92+            tombstones = emptyMap(),93+        ))94+        val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()95+        assertTrue(error is IllegalStateException)96+        assertTrue(error!!.message!!.contains("TOO_MANY_CHUNKS"))97+    }98+99+    @Test100+    fun `validates ADOPT_SNAPSHOT frames`() {101+        val valid = AdoptionFrame.Snapshot(102+            chunkIndex = 0, chunkCount = 1,103+            entries = mapOf("a" to "x"), tombstones = emptyMap(),104+        )105+        assertTrue(isAdoptSnapshot(valid))106+        assertTrue(!isAdoptSnapshot(null))107+        assertTrue(!isAdoptSnapshot(AdoptionFrame.Complete))108+    }109+110+    @Test111+    fun `snapshot_capable serializes in ADOPT_REQUEST`() {112+        val request = AdoptionFrame.Request(113+            sessionRequestId = "req-1",114+            deviceName = "test",115+            deviceType = "extension",116+            snapshotCapable = true,117+        )118+        val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), request)119+        assertTrue(json.contains("\"snapshot_capable\":true"))120+        // Absent when null (encodeDefaults = false)121+        val noFlag = AdoptionFrame.Request(122+            sessionRequestId = "req-2",123+            deviceName = "test",124+            deviceType = "extension",125+        )126+        val jsonNoFlag = AdoptJson.encodeToString(AdoptionFrame.serializer(), noFlag)127+        assertTrue(!jsonNoFlag.contains("snapshot_capable"))128+    }129+}

view file