VaultAdoptSnapshotTest.kt

26.7 KB · raw

1package com.peachpasswords.sync23import kotlinx.coroutines.channels.Channel4import kotlinx.coroutines.runBlocking5import org.junit.Assert.assertEquals6import org.junit.Assert.assertNull7import org.junit.Assert.assertTrue8import org.junit.Test910/**11 * Inline vault snapshot protocol parity with the browser12 * `vault-adopt-snapshot.test.ts`. Round-trip, chunking, and validation13 * tests for the ADOPT_SNAPSHOT / ADOPT_SNAPSHOT_APPLIED frames.14 */15class VaultAdoptSnapshotTest {1617    private suspend fun sendSnapshotFrame(channel: PairingTransport, frame: AdoptionFrame.Snapshot) {18        val raw = AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).encodeToByteArray()19        channel.send(SyncPadding.pad(raw))20    }2122    @Test23    fun `PB-26-706 snapshot frames use the shared sync padding wire format`() = runBlocking {24        val sent = mutableListOf<ByteArray>()25        val channel = object : PairingTransport {26            override suspend fun send(msg: ByteArray) { sent += msg }27            override suspend fun receive(): ByteArray = error("unused")28            override suspend fun close() {}29        }3031        sendVaultSnapshot(channel, mapOf("id-0" to "ciphertext"), emptyMap())3233        val wire = sent.single()34        assertEquals(SyncPadding.bucket(wire.size), wire.size)35        assertTrue(String(wire, Charsets.UTF_8).first() != '{')36        assertTrue(String(SyncPadding.unpad(wire), Charsets.UTF_8).startsWith("{\"type\":\"ADOPT_SNAPSHOT\""))37    }3839    private fun memoryPair(): Pair<PairingTransport, PairingTransport> {40        val a2b = Channel<ByteArray>(Channel.UNLIMITED)41        val b2a = Channel<ByteArray>(Channel.UNLIMITED)42        val a = object : PairingTransport {43            override suspend fun send(msg: ByteArray) { a2b.send(msg) }44            override suspend fun receive(): ByteArray = b2a.receive()45            override suspend fun close() {}46        }47        val b = object : PairingTransport {48            override suspend fun send(msg: ByteArray) { b2a.send(msg) }49            override suspend fun receive(): ByteArray = a2b.receive()50            override suspend fun close() {}51        }52        return a to b53    }5455    @Test56    fun `round-trips an empty vault`() = runBlocking {57        val (source, receiver) = memoryPair()58        sendVaultSnapshot(source, emptyMap(), emptyMap())59        val (entries, tombstones) = receiveVaultSnapshot(receiver)60        assertTrue(entries.isEmpty())61        assertTrue(tombstones.isEmpty())62    }6364    @Test65    fun `round-trips 50 entries and 5 tombstones in a single chunk`() = runBlocking {66        val (source, receiver) = memoryPair()67        val entries = (0 until 50).associate { "id-$it" to "cipher-$it" }68        val tombstones = (0 until 5).associate { "dead-$it" to (1000L + it) }69        sendVaultSnapshot(source, entries, tombstones)70        val (receivedEntries, receivedTombstones) = receiveVaultSnapshot(receiver)71        assertEquals(entries, receivedEntries)72        assertEquals(tombstones, receivedTombstones)73    }7475    @Test76    fun `splits a large vault into 3+ chunks and reassembles`() = runBlocking {77        val (source, receiver) = memoryPair()78        var frames = 079        val countingSource = object : PairingTransport {80            override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }81            override suspend fun receive(): ByteArray = source.receive()82            override suspend fun close() {}83        }84        val big = "x".repeat(200 * 1024)85        val entries = (0 until 5).associate { "id-$it" to big }86        val tombstones = mapOf("dead-0" to 42L)87        sendVaultSnapshot(countingSource, entries, tombstones)88        val (receivedEntries, receivedTombstones) = receiveVaultSnapshot(receiver)89        assertTrue("expected 3+ chunks, got $frames", frames >= 3)90        assertEquals(entries, receivedEntries)91        assertEquals(tombstones, receivedTombstones)92    }9394    @Test95    fun `rejects out-of-order chunks`() = runBlocking {96        val (source, receiver) = memoryPair()97        sendSnapshotFrame(source, AdoptionFrame.Snapshot(98            chunkIndex = 1,99            chunkCount = 2,100            entries = mapOf("a" to "x"),101            tombstones = emptyMap(),102        ))103        val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()104        assertTrue(error is IllegalStateException)105        assertTrue(error!!.message!!.contains("OUT_OF_ORDER"))106    }107108    @Test109    fun `rejects chunk_count exceeding MAX_SNAPSHOT_CHUNKS`() = runBlocking {110        val (source, receiver) = memoryPair()111        sendSnapshotFrame(source, AdoptionFrame.Snapshot(112            chunkIndex = 0,113            chunkCount = MAX_SNAPSHOT_CHUNKS + 1,114            entries = emptyMap(),115            tombstones = emptyMap(),116        ))117        val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()118        assertTrue(error is IllegalStateException)119        assertTrue(error!!.message!!.contains("TOO_MANY_CHUNKS"))120    }121122    @Test123    fun `validates ADOPT_SNAPSHOT frames`() {124        val valid = AdoptionFrame.Snapshot(125            chunkIndex = 0, chunkCount = 1,126            entries = mapOf("a" to "x"), tombstones = emptyMap(),127        )128        assertTrue(isAdoptSnapshot(valid))129        assertTrue(!isAdoptSnapshot(null))130        assertTrue(!isAdoptSnapshot(AdoptionFrame.Complete))131    }132133    @Test134    fun `snapshot_capable serializes in ADOPT_REQUEST`() {135        val request = AdoptionFrame.Request(136            sessionRequestId = "req-1",137            deviceName = "test",138            deviceType = "extension",139            snapshotCapable = true,140        )141        val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), request)142        assertTrue(json.contains("\"snapshot_capable\":true"))143        // Absent when null (encodeDefaults = false)144        val noFlag = AdoptionFrame.Request(145            sessionRequestId = "req-2",146            deviceName = "test",147            deviceType = "extension",148        )149        val jsonNoFlag = AdoptJson.encodeToString(AdoptionFrame.serializer(), noFlag)150        assertTrue(!jsonNoFlag.contains("snapshot_capable"))151    }152153    @Test154    fun `rejects padded frame exceeding wire ceiling`() = runBlocking {155        // H-09: a hostile source could send an arbitrarily large frame to exhaust156        // receiver memory before unpadding or JSON parsing. receiveVaultSnapshot157        // checks the padded wire length against the largest permitted sync bucket158        // and throws before attempting either operation.159        val oversized = ByteArray(MAX_PADDED_SNAPSHOT_FRAME_BYTES + 1)160        val channel = object : PairingTransport {161            override suspend fun send(msg: ByteArray) {}162            override suspend fun receive(): ByteArray = oversized163            override suspend fun close() {}164        }165        val error = runCatching { receiveVaultSnapshot(channel) }.exceptionOrNull()166        assertTrue(error is IllegalStateException)167        assertTrue(error!!.message!!.contains("CHUNK_TOO_LARGE"))168    }169170    @Test171    fun `rejects chunk_count mismatch on subsequent frame`() = runBlocking {172        val (source, receiver) = memoryPair()173        sendSnapshotFrame(source, AdoptionFrame.Snapshot(174            chunkIndex = 0,175            chunkCount = 2,176            entries = mapOf("a" to "x"),177            tombstones = emptyMap(),178        ))179        sendSnapshotFrame(source, AdoptionFrame.Snapshot(180            chunkIndex = 1,181            chunkCount = 3,182            entries = mapOf("b" to "y"),183            tombstones = emptyMap(),184        ))185        val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()186        assertTrue(error is IllegalStateException)187        assertTrue(error!!.message!!.contains("CHUNK_COUNT_MISMATCH"))188    }189190    @Test191    fun `round-trips duress_slot on final chunk`() = runBlocking {192        val (source, receiver) = memoryPair()193        val entries = mapOf("id-0" to "cipher-0")194        val tombstones = mapOf("dead-0" to 42L)195        val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""196        sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson)197        val snapshot = receiveVaultSnapshot(receiver)198        assertEquals(entries, snapshot.entries)199        assertEquals(tombstones, snapshot.tombstones)200        assertEquals(duressJson, snapshot.duressSlot)201    }202203    @Test204    fun `duress_slot absent returns null`() = runBlocking {205        val (source, receiver) = memoryPair()206        sendVaultSnapshot(source, mapOf("id-0" to "cipher-0"), emptyMap())207        val snapshot = receiveVaultSnapshot(receiver)208        assertNull(snapshot.duressSlot)209    }210211    @Test212    fun `duress_slot rides final chunk only in multi-chunk transfer`() = runBlocking {213        val (source, receiver) = memoryPair()214        val big = "x".repeat(200 * 1024)215        val entries = (0 until 5).associate { "id-$it" to big }216        val tombstones = mapOf("dead-0" to 42L)217        val duressJson = """{"version":1,"salt":"ff","nonce":"ee","ciphertext":"dd","tag":"cc"}"""218        sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson)219        val snapshot = receiveVaultSnapshot(receiver)220        assertEquals(entries, snapshot.entries)221        assertEquals(tombstones, snapshot.tombstones)222        assertEquals(duressJson, snapshot.duressSlot)223    }224225    @Test226    fun `old frame without duress_slot deserializes`() {227        val oldJson = """{"type":"ADOPT_SNAPSHOT","chunk_index":0,"chunk_count":1,"entries":{"a":"x"},"tombstones":{}}"""228        val frame = runCatching {229            AdoptJson.decodeFromString(AdoptionFrame.serializer(), oldJson)230        }.getOrNull()231        assertTrue(isAdoptSnapshot(frame))232        assertTrue((frame as AdoptionFrame.Snapshot).duressSlot == null)233    }234235    // Mirrors browser v4.6.4 T-A33: post-fix regression. With duress on236    // its own frame after the entries chunks, a >768 KiB combined payload237    // round-trips intact; pre-fix this threw ADOPT_SNAPSHOT_CHUNK_TOO_LARGE.238    @Test239    fun `frame ceiling 768KiB duress on separate frame round-trips`() = runBlocking {240        val (source, receiver) = memoryPair()241        val entries = mapOf("id-0" to "x".repeat(450 * 1024))242        val tombstones = mapOf("dead-0" to 1L)243        // Build a sealed-duress JSON literal with a 320 KiB ciphertext field244        // so combined raw payload (450 KiB entry + ~320 KiB duress + envelope)245        // crosses the 768 KiB SNAPSHOT_FRAME_WIRE_CEILING on the legacy wire.246        val duressJson = ("""{"version":1,"salt":"${"a".repeat(16)}",""" +247            """"nonce":"${"b".repeat(12)}",""" +248            """"ciphertext":"${"c".repeat(320 * 1024)}",""" +249            """"tag":"${"d".repeat(16)}"}""")250        sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson)251        val snapshot = receiveVaultSnapshot(receiver)252        assertEquals(entries, snapshot.entries)253        assertEquals(tombstones, snapshot.tombstones)254        assertEquals(duressJson, snapshot.duressSlot)255    }256257    // Mirrors browser v4.6.4 T-A34: structural proof that the duress wire258    // change is real. With >768 KiB combined, intermediate frames each stay259    // under the ceiling and exactly two frames are sent (entries + duress).260    @Test261    fun `intermediate frame sizes stay under wire ceiling when duress rides its own frame`() = runBlocking {262        val (source, _) = memoryPair()263        val observedRawSizes = mutableListOf<Int>()264        val observedWireSizes = mutableListOf<Int>()265        val observedHasDuress = mutableListOf<Boolean>()266        val observedEntriesSize = mutableListOf<Int>()267        val probe = object : PairingTransport {268            override suspend fun send(msg: ByteArray) {269                observedWireSizes.add(msg.size)270                val raw = SyncPadding.unpad(msg)271                observedRawSizes.add(raw.size)272                val parsed = runCatching {273                    AdoptJson.decodeFromString(AdoptionFrame.Snapshot.serializer(), String(raw, Charsets.UTF_8))274                }.getOrNull()275                observedHasDuress.add(parsed?.duressSlot != null)276                observedEntriesSize.add(parsed?.entries?.size ?: 0)277                source.send(msg)278            }279            override suspend fun receive(): ByteArray = source.receive()280            override suspend fun close() {}281        }282        val entries = mapOf("id-0" to "x".repeat(450 * 1024))283        val duressJson = "y".repeat(320 * 1024)284        val ceiling = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024 // SNAPSHOT_FRAME_WIRE_CEILING285286        sendVaultSnapshot(probe, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)287288        assertEquals(2, observedRawSizes.size)289        assertTrue(observedWireSizes.all { it == SyncPadding.bucket(it) })290        // Entries frame carries entries, no duress, under ceiling.291        assertEquals(false, observedHasDuress[0])292        assertEquals(1, observedEntriesSize[0])293        org.junit.Assert.assertTrue(294            "entries frame ${observedRawSizes[0]} should be < ceiling $ceiling",295            observedRawSizes[0] < ceiling296        )297        // Duress frame carries duress, no entries, under ceiling.298        assertEquals(true, observedHasDuress[1])299        assertEquals(0, observedEntriesSize[1])300        org.junit.Assert.assertTrue(301            "duress frame ${observedRawSizes[1]} should be < ceiling $ceiling",302            observedRawSizes[1] < ceiling303        )304        // The point of the split: combined raw exceeds what a single frame305        // was permitted to carry on the old wire.306        org.junit.Assert.assertTrue(307            "combined raw ${observedRawSizes[0] + observedRawSizes[1]} should exceed ceiling $ceiling",308            observedRawSizes[0] + observedRawSizes[1] > ceiling309        )310    }311312    // Mirrors browser v4.6.4 T-A35: backward compat with pre-v2.9.9 senders313    // (duress on entries final chunk). The receiver merges per chunk so both314    // shapes decode.315    @Test316    fun `legacy duress on entries final chunk still decodes`() = runBlocking {317        val (source, receiver) = memoryPair()318        val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""319        // Manually construct the legacy v2.9.8 wire shape: a single chunk320        // with entries, tombstones AND duress_slot all on it.321        sendSnapshotFrame(source, AdoptionFrame.Snapshot(322            chunkIndex = 0,323            chunkCount = 1,324            entries = mapOf("id-0" to "ciphertext"),325            tombstones = mapOf("dead-0" to 42L),326            duressSlot = duressJson,327        ))328        val snapshot = receiveVaultSnapshot(receiver)329        assertEquals(mapOf("id-0" to "ciphertext"), snapshot.entries)330        assertEquals(mapOf("dead-0" to 42L), snapshot.tombstones)331        assertEquals(duressJson, snapshot.duressSlot)332    }333334    // Mirrors browser T-A36: cap boundary inclusive. With 199 entries and a335    // duress slot, the sender fits everything within MAX_SNAPSHOT_CHUNKS = 200336    // (199 entries + 1 duress = 200 frames). Entries are 40 KiB so the ~8 MiB337    // total also fits the PB-26-206 10 MiB aggregate budget.338    @Test339    fun `duress is reserved a frame so 199 entries plus duress fits in MAX_SNAPSHOT_CHUNKS`() = runBlocking {340        val (source, receiver) = memoryPair()341        val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""342        val big = "x".repeat(40 * 1024)343        val entries = (0 until 199).associate { "id-$it" to big }344        sendVaultSnapshot(source, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)345        val snapshot = receiveVaultSnapshot(receiver)346        assertEquals(entries, snapshot.entries)347        assertEquals(mapOf("dead-0" to 1L), snapshot.tombstones)348        assertEquals(duressJson, snapshot.duressSlot)349    }350351    // Mirrors browser T-A37: PB-26-206 supersedes the old chunk-cap352    // truncation. An over-budget snapshot is never partially sent: the sender353    // preflights with the same aggregate helper the receiver enforces and354    // throws before the first frame, so the caller falls through to the355    // regular sync pipeline instead of sending a truncated snapshot.356    @Test357    fun `over-budget snapshot preflights with zero frames sent`() = runBlocking {358        val (source, _) = memoryPair()359        var frames = 0360        val countingSource = object : PairingTransport {361            override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }362            override suspend fun receive(): ByteArray = source.receive()363            override suspend fun close() {}364        }365        val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""366        // 200 entries × 60 KiB ≈ 12 MiB > 10 MiB aggregate budget.367        val big = "x".repeat(60 * 1024)368        val entries = (0 until 200).associate { "id-$it" to big }369        val error = runCatching {370            sendVaultSnapshot(countingSource, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)371        }.exceptionOrNull()372        assertTrue(error is IllegalStateException)373        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))374        assertEquals(0, frames)375    }376377    // Cap-pre-check guardrail: a duress payload that itself exceeds the wire378    // ceiling throws ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE before any frame is379    // sent — adoption never silently loses duress.380    @Test381    fun `duress larger than wire ceiling throws loudly before sending`() = runBlocking {382        val (source, _) = memoryPair()383        var frames = 0384        val countingSource = object : PairingTransport {385            override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }386            override suspend fun receive(): ByteArray = source.receive()387            override suspend fun close() {}388        }389        val ceiling = 768 * 1024390        val emptyWireSize = AdoptJson.encodeToString(391            AdoptionFrame.serializer(),392            AdoptionFrame.Snapshot(1, 2, emptyMap(), emptyMap(), ""),393        ).encodeToByteArray().size394        val duressJson = "x".repeat(ceiling - emptyWireSize + 1)395        val error = runCatching {396            sendVaultSnapshot(countingSource, mapOf("id-0" to "small"), emptyMap(), duressSlot = duressJson)397        }.exceptionOrNull()398        assertTrue(error is SnapshotDuressFrameTooLargeException)399        assertEquals(0, frames)400    }401402    // PB-26-206 aggregate budget vectors V1..V11. The same wire shapes and403    // verdicts are asserted in the browser's vault-adopt-snapshot.test.ts —404    // both clients must agree on accept/reject for every vector.405406    @Test407    fun `V1 frame bytes at the aggregate limit pass, limit plus one rejects before merge`() {408        val atLimit = createAdoptSnapshotBudget()409        atLimit.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100410        applyAdoptSnapshotFrame(atLimit, mapOf("a" to "x"), emptyMap(), null, 100)411        assertEquals(MAX_ADOPT_SNAPSHOT_BYTES, atLimit.bytes)412413        val over = createAdoptSnapshotBudget()414        over.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100415        val error = runCatching {416            applyAdoptSnapshotFrame(over, mapOf("a" to "x"), emptyMap(), null, 101)417        }.exceptionOrNull()418        assertTrue(error is IllegalStateException)419        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))420        // Rejected before merge: no partial adopted state escapes.421        assertTrue(over.entries.isEmpty())422    }423424    @Test425    fun `V2 25000 entries pass, the 25001st rejects`() {426        val entries = (0 until MAX_ADOPT_SNAPSHOT_ENTRIES).associate { "e$it" to "v" }427        val acc = createAdoptSnapshotBudget()428        applyAdoptSnapshotFrame(acc, entries, emptyMap(), null, 100)429        assertEquals(MAX_ADOPT_SNAPSHOT_ENTRIES, acc.entryCount)430        val error = runCatching {431            applyAdoptSnapshotFrame(acc, mapOf("oneMore" to "v"), emptyMap(), null, 1)432        }.exceptionOrNull()433        assertTrue(error is IllegalStateException)434        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES"))435        assertNull(acc.entries["oneMore"])436    }437438    @Test439    fun `V3 25000 tombstones pass, the 25001st rejects`() {440        val tombstones = (0 until MAX_ADOPT_SNAPSHOT_TOMBSTONES).associate { "dead-$it" to (it + 1L) }441        val acc = createAdoptSnapshotBudget()442        applyAdoptSnapshotFrame(acc, emptyMap(), tombstones, null, 100)443        assertEquals(MAX_ADOPT_SNAPSHOT_TOMBSTONES, acc.tombstoneCount)444        val error = runCatching {445            applyAdoptSnapshotFrame(acc, emptyMap(), mapOf("oneMore" to 1L), null, 1)446        }.exceptionOrNull()447        assertTrue(error is IllegalStateException)448        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES"))449        assertNull(acc.tombstones["oneMore"])450    }451452    @Test453    fun `V4 entry and tombstone IDs of 512 chars pass, 513 reject`() {454        val ok = "k".repeat(MAX_ADOPT_SNAPSHOT_ID_CHARS)455        val acc = createAdoptSnapshotBudget()456        applyAdoptSnapshotFrame(acc, mapOf(ok to "v"), mapOf(ok to 1L), null, 10)457        assertEquals("v", acc.entries[ok])458459        val tooLong = "k".repeat(MAX_ADOPT_SNAPSHOT_ID_CHARS + 1)460        val entryError = runCatching {461            applyAdoptSnapshotFrame(createAdoptSnapshotBudget(), mapOf(tooLong to "v"), emptyMap(), null, 10)462        }.exceptionOrNull()463        assertTrue(entryError is IllegalStateException)464        assertTrue(entryError!!.message!!.contains("ADOPT_SNAPSHOT_ID_TOO_LONG"))465        val tombstoneError = runCatching {466            applyAdoptSnapshotFrame(createAdoptSnapshotBudget(), emptyMap(), mapOf(tooLong to 1L), null, 10)467        }.exceptionOrNull()468        assertTrue(tombstoneError is IllegalStateException)469        assertTrue(tombstoneError!!.message!!.contains("ADOPT_SNAPSHOT_ID_TOO_LONG"))470    }471472    @Test473    fun `V5 envelope at the base64 overhead ceiling passes, plus one rejects`() {474        val atLimit = "c".repeat(MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES)475        val acc = createAdoptSnapshotBudget()476        applyAdoptSnapshotFrame(acc, mapOf("e1" to atLimit), emptyMap(), null, 10)477        assertEquals(atLimit, acc.entries["e1"])478479        val over = "c".repeat(MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES + 1)480        val error = runCatching {481            applyAdoptSnapshotFrame(createAdoptSnapshotBudget(), mapOf("e1" to over), emptyMap(), null, 10)482        }.exceptionOrNull()483        assertTrue(error is IllegalStateException)484        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE"))485    }486487    @Test488    fun `V6 duress slot at the wire frame ceiling passes, plus one rejects`() {489        val acc = createAdoptSnapshotBudget()490        applyAdoptSnapshotFrame(acc, emptyMap(), emptyMap(), "d".repeat(SNAPSHOT_FRAME_WIRE_CEILING), 10)491        assertEquals(SNAPSHOT_FRAME_WIRE_CEILING, acc.duressSlot!!.length)492493        val error = runCatching {494            applyAdoptSnapshotFrame(495                createAdoptSnapshotBudget(), emptyMap(), emptyMap(),496                "d".repeat(SNAPSHOT_FRAME_WIRE_CEILING + 1), 10,497            )498        }.exceptionOrNull()499        assertTrue(error is IllegalStateException)500        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"))501    }502503    @Test504    fun `V7 non-integer tombstone JSON rejects before merge`() = runBlocking {505        // Kotlin's Long type cannot hold NaN/Infinity, so hostile values die506        // one layer earlier than on the browser: the strict JSON decode fails507        // and the frame is rejected before any merge. Same verdict as the508        // browser's V7 (reject), enforced at the codec boundary.509        for (hostileValue in listOf("1.5", "99999999999999999999999", "NaN")) {510            val (source, receiver) = memoryPair()511            val hostile = """{"type":"ADOPT_SNAPSHOT","chunk_index":0,"chunk_count":1,"entries":{},"tombstones":{"dead":$hostileValue}}"""512            source.send(SyncPadding.pad(hostile.encodeToByteArray()))513            val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()514            assertTrue("value $hostileValue must reject", error is IllegalStateException)515            assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_INVALID_FRAME"))516        }517    }518519    @Test520    fun `V8 high-count low-byte snapshot round-trips 25000 tiny entries`() = runBlocking {521        val (source, receiver) = memoryPair()522        val entries = (0 until MAX_ADOPT_SNAPSHOT_ENTRIES).associate { "e$it" to "v" }523        sendVaultSnapshot(source, entries, emptyMap())524        val snapshot = receiveVaultSnapshot(receiver)525        assertEquals(MAX_ADOPT_SNAPSHOT_ENTRIES, snapshot.entries.size)526    }527528    @Test529    fun `V9 low-count high-byte stream over the aggregate rejects with no partial result`() = runBlocking {530        // 16 envelopes of ~674 KiB each: every frame fits the 768 KiB wire531        // ceiling, but the ~10.8 MiB total exceeds the 10 MiB aggregate.532        val (source, receiver) = memoryPair()533        val envelope = "c".repeat(690_000)534        val chunks = 16535        // Channels buffer UNLIMITED, so all frames can be queued before the536        // receiver starts — sequential style matches the rest of this file.537        for (i in 0 until chunks) {538            sendSnapshotFrame(source, AdoptionFrame.Snapshot(539                chunkIndex = i,540                chunkCount = chunks,541                entries = mapOf("big-$i" to envelope),542                tombstones = emptyMap(),543            ))544        }545        val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()546        assertTrue(error is IllegalStateException)547        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))548    }549550    @Test551    fun `V10 over-budget sender preflights with zero frames sent`() = runBlocking {552        val (source, _) = memoryPair()553        var frames = 0554        val countingSource = object : PairingTransport {555            override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }556            override suspend fun receive(): ByteArray = source.receive()557            override suspend fun close() {}558        }559        val envelope = "c".repeat(690_000)560        val entries = (0 until 16).associate { "big-$it" to envelope }561        val error = runCatching {562            sendVaultSnapshot(countingSource, entries, emptyMap())563        }.exceptionOrNull()564        assertTrue(error is IllegalStateException)565        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))566        assertEquals(0, frames)567    }568569    @Test570    fun `V11 duress bytes count against the aggregate budget`() {571        val acc = createAdoptSnapshotBudget()572        acc.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100573        // A 200-byte duress slot does not fit the remaining 100-byte budget.574        val error = runCatching {575            applyAdoptSnapshotFrame(acc, emptyMap(), emptyMap(), "d".repeat(200), 200)576        }.exceptionOrNull()577        assertTrue(error is IllegalStateException)578        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))579    }580}