VaultAdoptSnapshotTest.kt

27.8 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        assertEquals(2, sent.size)34        sent.forEach { wire ->35            assertEquals(SyncPadding.bucket(wire.size), wire.size)36            assertTrue(String(wire, Charsets.UTF_8).first() != '{')37            assertTrue(String(SyncPadding.unpad(wire), Charsets.UTF_8).startsWith("{\"type\":\"ADOPT_SNAPSHOT\""))38        }3940        val supplemental = AdoptJson.decodeFromString(41            AdoptionFrame.Snapshot.serializer(),42            String(SyncPadding.unpad(sent.last()), Charsets.UTF_8),43        )44        assertTrue(supplemental.entries.isEmpty())45        assertTrue(supplemental.tombstones.isEmpty())46        assertNull(supplemental.duressSlot)47    }4849    @Test50    fun `duress and no-duress snapshots use the same frame count`() = runBlocking {51        suspend fun frameCount(duressSlot: String?): Int {52            val sent = mutableListOf<ByteArray>()53            val channel = object : PairingTransport {54                override suspend fun send(msg: ByteArray) { sent += msg }55                override suspend fun receive(): ByteArray = error("unused")56                override suspend fun close() {}57            }58            sendVaultSnapshot(channel, mapOf("id-0" to "ciphertext"), emptyMap(), duressSlot)59            return sent.size60        }6162        val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""63        assertEquals(frameCount(null), frameCount(duressJson))64    }6566    private fun memoryPair(): Pair<PairingTransport, PairingTransport> {67        val a2b = Channel<ByteArray>(Channel.UNLIMITED)68        val b2a = Channel<ByteArray>(Channel.UNLIMITED)69        val a = object : PairingTransport {70            override suspend fun send(msg: ByteArray) { a2b.send(msg) }71            override suspend fun receive(): ByteArray = b2a.receive()72            override suspend fun close() {}73        }74        val b = object : PairingTransport {75            override suspend fun send(msg: ByteArray) { b2a.send(msg) }76            override suspend fun receive(): ByteArray = a2b.receive()77            override suspend fun close() {}78        }79        return a to b80    }8182    @Test83    fun `round-trips an empty vault`() = runBlocking {84        val (source, receiver) = memoryPair()85        sendVaultSnapshot(source, emptyMap(), emptyMap())86        val (entries, tombstones) = receiveVaultSnapshot(receiver)87        assertTrue(entries.isEmpty())88        assertTrue(tombstones.isEmpty())89    }9091    @Test92    fun `round-trips 50 entries and 5 tombstones in a single chunk`() = runBlocking {93        val (source, receiver) = memoryPair()94        val entries = (0 until 50).associate { "id-$it" to "cipher-$it" }95        val tombstones = (0 until 5).associate { "dead-$it" to (1000L + it) }96        sendVaultSnapshot(source, entries, tombstones)97        val (receivedEntries, receivedTombstones) = receiveVaultSnapshot(receiver)98        assertEquals(entries, receivedEntries)99        assertEquals(tombstones, receivedTombstones)100    }101102    @Test103    fun `splits a large vault into 3+ chunks and reassembles`() = runBlocking {104        val (source, receiver) = memoryPair()105        var frames = 0106        val countingSource = object : PairingTransport {107            override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }108            override suspend fun receive(): ByteArray = source.receive()109            override suspend fun close() {}110        }111        val big = "x".repeat(200 * 1024)112        val entries = (0 until 5).associate { "id-$it" to big }113        val tombstones = mapOf("dead-0" to 42L)114        sendVaultSnapshot(countingSource, entries, tombstones)115        val (receivedEntries, receivedTombstones) = receiveVaultSnapshot(receiver)116        assertTrue("expected 3+ chunks, got $frames", frames >= 3)117        assertEquals(entries, receivedEntries)118        assertEquals(tombstones, receivedTombstones)119    }120121    @Test122    fun `rejects out-of-order chunks`() = runBlocking {123        val (source, receiver) = memoryPair()124        sendSnapshotFrame(source, AdoptionFrame.Snapshot(125            chunkIndex = 1,126            chunkCount = 2,127            entries = mapOf("a" to "x"),128            tombstones = emptyMap(),129        ))130        val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()131        assertTrue(error is IllegalStateException)132        assertTrue(error!!.message!!.contains("OUT_OF_ORDER"))133    }134135    @Test136    fun `rejects chunk_count exceeding MAX_SNAPSHOT_CHUNKS`() = runBlocking {137        val (source, receiver) = memoryPair()138        sendSnapshotFrame(source, AdoptionFrame.Snapshot(139            chunkIndex = 0,140            chunkCount = MAX_SNAPSHOT_CHUNKS + 1,141            entries = emptyMap(),142            tombstones = emptyMap(),143        ))144        val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()145        assertTrue(error is IllegalStateException)146        assertTrue(error!!.message!!.contains("TOO_MANY_CHUNKS"))147    }148149    @Test150    fun `validates ADOPT_SNAPSHOT frames`() {151        val valid = AdoptionFrame.Snapshot(152            chunkIndex = 0, chunkCount = 1,153            entries = mapOf("a" to "x"), tombstones = emptyMap(),154        )155        assertTrue(isAdoptSnapshot(valid))156        assertTrue(!isAdoptSnapshot(null))157        assertTrue(!isAdoptSnapshot(AdoptionFrame.Complete))158    }159160    @Test161    fun `snapshot_capable serializes in ADOPT_REQUEST`() {162        val request = AdoptionFrame.Request(163            sessionRequestId = "req-1",164            deviceName = "test",165            deviceType = "extension",166            snapshotCapable = true,167        )168        val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), request)169        assertTrue(json.contains("\"snapshot_capable\":true"))170        // Absent when null (encodeDefaults = false)171        val noFlag = AdoptionFrame.Request(172            sessionRequestId = "req-2",173            deviceName = "test",174            deviceType = "extension",175        )176        val jsonNoFlag = AdoptJson.encodeToString(AdoptionFrame.serializer(), noFlag)177        assertTrue(!jsonNoFlag.contains("snapshot_capable"))178    }179180    @Test181    fun `rejects padded frame exceeding wire ceiling`() = runBlocking {182        // H-09: a hostile source could send an arbitrarily large frame to exhaust183        // receiver memory before unpadding or JSON parsing. receiveVaultSnapshot184        // checks the padded wire length against the largest permitted sync bucket185        // and throws before attempting either operation.186        val oversized = ByteArray(MAX_PADDED_SNAPSHOT_FRAME_BYTES + 1)187        val channel = object : PairingTransport {188            override suspend fun send(msg: ByteArray) {}189            override suspend fun receive(): ByteArray = oversized190            override suspend fun close() {}191        }192        val error = runCatching { receiveVaultSnapshot(channel) }.exceptionOrNull()193        assertTrue(error is IllegalStateException)194        assertTrue(error!!.message!!.contains("CHUNK_TOO_LARGE"))195    }196197    @Test198    fun `rejects chunk_count mismatch on subsequent frame`() = runBlocking {199        val (source, receiver) = memoryPair()200        sendSnapshotFrame(source, AdoptionFrame.Snapshot(201            chunkIndex = 0,202            chunkCount = 2,203            entries = mapOf("a" to "x"),204            tombstones = emptyMap(),205        ))206        sendSnapshotFrame(source, AdoptionFrame.Snapshot(207            chunkIndex = 1,208            chunkCount = 3,209            entries = mapOf("b" to "y"),210            tombstones = emptyMap(),211        ))212        val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()213        assertTrue(error is IllegalStateException)214        assertTrue(error!!.message!!.contains("CHUNK_COUNT_MISMATCH"))215    }216217    @Test218    fun `round-trips duress_slot on final chunk`() = runBlocking {219        val (source, receiver) = memoryPair()220        val entries = mapOf("id-0" to "cipher-0")221        val tombstones = mapOf("dead-0" to 42L)222        val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""223        sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson)224        val snapshot = receiveVaultSnapshot(receiver)225        assertEquals(entries, snapshot.entries)226        assertEquals(tombstones, snapshot.tombstones)227        assertEquals(duressJson, snapshot.duressSlot)228    }229230    @Test231    fun `duress_slot absent returns null`() = runBlocking {232        val (source, receiver) = memoryPair()233        sendVaultSnapshot(source, mapOf("id-0" to "cipher-0"), emptyMap())234        val snapshot = receiveVaultSnapshot(receiver)235        assertNull(snapshot.duressSlot)236    }237238    @Test239    fun `duress_slot rides final chunk only in multi-chunk transfer`() = runBlocking {240        val (source, receiver) = memoryPair()241        val big = "x".repeat(200 * 1024)242        val entries = (0 until 5).associate { "id-$it" to big }243        val tombstones = mapOf("dead-0" to 42L)244        val duressJson = """{"version":1,"salt":"ff","nonce":"ee","ciphertext":"dd","tag":"cc"}"""245        sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson)246        val snapshot = receiveVaultSnapshot(receiver)247        assertEquals(entries, snapshot.entries)248        assertEquals(tombstones, snapshot.tombstones)249        assertEquals(duressJson, snapshot.duressSlot)250    }251252    @Test253    fun `old frame without duress_slot deserializes`() {254        val oldJson = """{"type":"ADOPT_SNAPSHOT","chunk_index":0,"chunk_count":1,"entries":{"a":"x"},"tombstones":{}}"""255        val frame = runCatching {256            AdoptJson.decodeFromString(AdoptionFrame.serializer(), oldJson)257        }.getOrNull()258        assertTrue(isAdoptSnapshot(frame))259        assertTrue((frame as AdoptionFrame.Snapshot).duressSlot == null)260    }261262    // Mirrors browser v4.6.4 T-A33: post-fix regression. With duress on263    // its own frame after the entries chunks, a >768 KiB combined payload264    // round-trips intact; pre-fix this threw ADOPT_SNAPSHOT_CHUNK_TOO_LARGE.265    @Test266    fun `frame ceiling 768KiB duress on separate frame round-trips`() = runBlocking {267        val (source, receiver) = memoryPair()268        val entries = mapOf("id-0" to "x".repeat(450 * 1024))269        val tombstones = mapOf("dead-0" to 1L)270        // Build a sealed-duress JSON literal with a 320 KiB ciphertext field271        // so combined raw payload (450 KiB entry + ~320 KiB duress + envelope)272        // crosses the 768 KiB SNAPSHOT_FRAME_WIRE_CEILING on the legacy wire.273        val duressJson = ("""{"version":1,"salt":"${"a".repeat(16)}",""" +274            """"nonce":"${"b".repeat(12)}",""" +275            """"ciphertext":"${"c".repeat(320 * 1024)}",""" +276            """"tag":"${"d".repeat(16)}"}""")277        sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson)278        val snapshot = receiveVaultSnapshot(receiver)279        assertEquals(entries, snapshot.entries)280        assertEquals(tombstones, snapshot.tombstones)281        assertEquals(duressJson, snapshot.duressSlot)282    }283284    // Mirrors browser v4.6.4 T-A34: structural proof that the duress wire285    // change is real. With >768 KiB combined, intermediate frames each stay286    // under the ceiling and exactly two frames are sent (entries + duress).287    @Test288    fun `intermediate frame sizes stay under wire ceiling when duress rides its own frame`() = runBlocking {289        val (source, _) = memoryPair()290        val observedRawSizes = mutableListOf<Int>()291        val observedWireSizes = mutableListOf<Int>()292        val observedHasDuress = mutableListOf<Boolean>()293        val observedEntriesSize = mutableListOf<Int>()294        val probe = object : PairingTransport {295            override suspend fun send(msg: ByteArray) {296                observedWireSizes.add(msg.size)297                val raw = SyncPadding.unpad(msg)298                observedRawSizes.add(raw.size)299                val parsed = runCatching {300                    AdoptJson.decodeFromString(AdoptionFrame.Snapshot.serializer(), String(raw, Charsets.UTF_8))301                }.getOrNull()302                observedHasDuress.add(parsed?.duressSlot != null)303                observedEntriesSize.add(parsed?.entries?.size ?: 0)304                source.send(msg)305            }306            override suspend fun receive(): ByteArray = source.receive()307            override suspend fun close() {}308        }309        val entries = mapOf("id-0" to "x".repeat(450 * 1024))310        val duressJson = "y".repeat(320 * 1024)311        val ceiling = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024 // SNAPSHOT_FRAME_WIRE_CEILING312313        sendVaultSnapshot(probe, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)314315        assertEquals(2, observedRawSizes.size)316        assertTrue(observedWireSizes.all { it == SyncPadding.bucket(it) })317        // Entries frame carries entries, no duress, under ceiling.318        assertEquals(false, observedHasDuress[0])319        assertEquals(1, observedEntriesSize[0])320        org.junit.Assert.assertTrue(321            "entries frame ${observedRawSizes[0]} should be < ceiling $ceiling",322            observedRawSizes[0] < ceiling323        )324        // Duress frame carries duress, no entries, under ceiling.325        assertEquals(true, observedHasDuress[1])326        assertEquals(0, observedEntriesSize[1])327        org.junit.Assert.assertTrue(328            "duress frame ${observedRawSizes[1]} should be < ceiling $ceiling",329            observedRawSizes[1] < ceiling330        )331        // The point of the split: combined raw exceeds what a single frame332        // was permitted to carry on the old wire.333        org.junit.Assert.assertTrue(334            "combined raw ${observedRawSizes[0] + observedRawSizes[1]} should exceed ceiling $ceiling",335            observedRawSizes[0] + observedRawSizes[1] > ceiling336        )337    }338339    // Mirrors browser v4.6.4 T-A35: backward compat with pre-v2.9.9 senders340    // (duress on entries final chunk). The receiver merges per chunk so both341    // shapes decode.342    @Test343    fun `legacy duress on entries final chunk still decodes`() = runBlocking {344        val (source, receiver) = memoryPair()345        val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""346        // Manually construct the legacy v2.9.8 wire shape: a single chunk347        // with entries, tombstones AND duress_slot all on it.348        sendSnapshotFrame(source, AdoptionFrame.Snapshot(349            chunkIndex = 0,350            chunkCount = 1,351            entries = mapOf("id-0" to "ciphertext"),352            tombstones = mapOf("dead-0" to 42L),353            duressSlot = duressJson,354        ))355        val snapshot = receiveVaultSnapshot(receiver)356        assertEquals(mapOf("id-0" to "ciphertext"), snapshot.entries)357        assertEquals(mapOf("dead-0" to 42L), snapshot.tombstones)358        assertEquals(duressJson, snapshot.duressSlot)359    }360361    // Mirrors browser T-A36: cap boundary inclusive. With 199 entries and a362    // duress slot, the sender fits everything within MAX_SNAPSHOT_CHUNKS = 200363    // (199 entries + 1 duress = 200 frames). Entries are 40 KiB so the ~8 MiB364    // total also fits the PB-26-206 10 MiB aggregate budget.365    @Test366    fun `duress is reserved a frame so 199 entries plus duress fits in MAX_SNAPSHOT_CHUNKS`() = runBlocking {367        val (source, receiver) = memoryPair()368        val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""369        val big = "x".repeat(40 * 1024)370        val entries = (0 until 199).associate { "id-$it" to big }371        sendVaultSnapshot(source, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)372        val snapshot = receiveVaultSnapshot(receiver)373        assertEquals(entries, snapshot.entries)374        assertEquals(mapOf("dead-0" to 1L), snapshot.tombstones)375        assertEquals(duressJson, snapshot.duressSlot)376    }377378    // Mirrors browser T-A37: PB-26-206 supersedes the old chunk-cap379    // truncation. An over-budget snapshot is never partially sent: the sender380    // preflights with the same aggregate helper the receiver enforces and381    // throws before the first frame, so the caller falls through to the382    // regular sync pipeline instead of sending a truncated snapshot.383    @Test384    fun `over-budget snapshot preflights with zero frames sent`() = runBlocking {385        val (source, _) = memoryPair()386        var frames = 0387        val countingSource = object : PairingTransport {388            override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }389            override suspend fun receive(): ByteArray = source.receive()390            override suspend fun close() {}391        }392        val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""393        // 200 entries × 60 KiB ≈ 12 MiB > 10 MiB aggregate budget.394        val big = "x".repeat(60 * 1024)395        val entries = (0 until 200).associate { "id-$it" to big }396        val error = runCatching {397            sendVaultSnapshot(countingSource, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)398        }.exceptionOrNull()399        assertTrue(error is IllegalStateException)400        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))401        assertEquals(0, frames)402    }403404    // Cap-pre-check guardrail: a duress payload that itself exceeds the wire405    // ceiling throws ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE before any frame is406    // sent — adoption never silently loses duress.407    @Test408    fun `duress larger than wire ceiling throws loudly before sending`() = runBlocking {409        val (source, _) = memoryPair()410        var frames = 0411        val countingSource = object : PairingTransport {412            override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }413            override suspend fun receive(): ByteArray = source.receive()414            override suspend fun close() {}415        }416        val ceiling = 768 * 1024417        val emptyWireSize = AdoptJson.encodeToString(418            AdoptionFrame.serializer(),419            AdoptionFrame.Snapshot(1, 2, emptyMap(), emptyMap(), ""),420        ).encodeToByteArray().size421        val duressJson = "x".repeat(ceiling - emptyWireSize + 1)422        val error = runCatching {423            sendVaultSnapshot(countingSource, mapOf("id-0" to "small"), emptyMap(), duressSlot = duressJson)424        }.exceptionOrNull()425        assertTrue(error is SnapshotDuressFrameTooLargeException)426        assertEquals(0, frames)427    }428429    // PB-26-206 aggregate budget vectors V1..V11. The same wire shapes and430    // verdicts are asserted in the browser's vault-adopt-snapshot.test.ts —431    // both clients must agree on accept/reject for every vector.432433    @Test434    fun `V1 frame bytes at the aggregate limit pass, limit plus one rejects before merge`() {435        val atLimit = createAdoptSnapshotBudget()436        atLimit.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100437        applyAdoptSnapshotFrame(atLimit, mapOf("a" to "x"), emptyMap(), null, 100)438        assertEquals(MAX_ADOPT_SNAPSHOT_BYTES, atLimit.bytes)439440        val over = createAdoptSnapshotBudget()441        over.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100442        val error = runCatching {443            applyAdoptSnapshotFrame(over, mapOf("a" to "x"), emptyMap(), null, 101)444        }.exceptionOrNull()445        assertTrue(error is IllegalStateException)446        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))447        // Rejected before merge: no partial adopted state escapes.448        assertTrue(over.entries.isEmpty())449    }450451    @Test452    fun `V2 25000 entries pass, the 25001st rejects`() {453        val entries = (0 until MAX_ADOPT_SNAPSHOT_ENTRIES).associate { "e$it" to "v" }454        val acc = createAdoptSnapshotBudget()455        applyAdoptSnapshotFrame(acc, entries, emptyMap(), null, 100)456        assertEquals(MAX_ADOPT_SNAPSHOT_ENTRIES, acc.entryCount)457        val error = runCatching {458            applyAdoptSnapshotFrame(acc, mapOf("oneMore" to "v"), emptyMap(), null, 1)459        }.exceptionOrNull()460        assertTrue(error is IllegalStateException)461        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES"))462        assertNull(acc.entries["oneMore"])463    }464465    @Test466    fun `V3 25000 tombstones pass, the 25001st rejects`() {467        val tombstones = (0 until MAX_ADOPT_SNAPSHOT_TOMBSTONES).associate { "dead-$it" to (it + 1L) }468        val acc = createAdoptSnapshotBudget()469        applyAdoptSnapshotFrame(acc, emptyMap(), tombstones, null, 100)470        assertEquals(MAX_ADOPT_SNAPSHOT_TOMBSTONES, acc.tombstoneCount)471        val error = runCatching {472            applyAdoptSnapshotFrame(acc, emptyMap(), mapOf("oneMore" to 1L), null, 1)473        }.exceptionOrNull()474        assertTrue(error is IllegalStateException)475        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES"))476        assertNull(acc.tombstones["oneMore"])477    }478479    @Test480    fun `V4 entry and tombstone IDs of 512 chars pass, 513 reject`() {481        val ok = "k".repeat(MAX_ADOPT_SNAPSHOT_ID_CHARS)482        val acc = createAdoptSnapshotBudget()483        applyAdoptSnapshotFrame(acc, mapOf(ok to "v"), mapOf(ok to 1L), null, 10)484        assertEquals("v", acc.entries[ok])485486        val tooLong = "k".repeat(MAX_ADOPT_SNAPSHOT_ID_CHARS + 1)487        val entryError = runCatching {488            applyAdoptSnapshotFrame(createAdoptSnapshotBudget(), mapOf(tooLong to "v"), emptyMap(), null, 10)489        }.exceptionOrNull()490        assertTrue(entryError is IllegalStateException)491        assertTrue(entryError!!.message!!.contains("ADOPT_SNAPSHOT_ID_TOO_LONG"))492        val tombstoneError = runCatching {493            applyAdoptSnapshotFrame(createAdoptSnapshotBudget(), emptyMap(), mapOf(tooLong to 1L), null, 10)494        }.exceptionOrNull()495        assertTrue(tombstoneError is IllegalStateException)496        assertTrue(tombstoneError!!.message!!.contains("ADOPT_SNAPSHOT_ID_TOO_LONG"))497    }498499    @Test500    fun `V5 envelope at the base64 overhead ceiling passes, plus one rejects`() {501        val atLimit = "c".repeat(MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES)502        val acc = createAdoptSnapshotBudget()503        applyAdoptSnapshotFrame(acc, mapOf("e1" to atLimit), emptyMap(), null, 10)504        assertEquals(atLimit, acc.entries["e1"])505506        val over = "c".repeat(MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES + 1)507        val error = runCatching {508            applyAdoptSnapshotFrame(createAdoptSnapshotBudget(), mapOf("e1" to over), emptyMap(), null, 10)509        }.exceptionOrNull()510        assertTrue(error is IllegalStateException)511        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE"))512    }513514    @Test515    fun `V6 duress slot at the wire frame ceiling passes, plus one rejects`() {516        val acc = createAdoptSnapshotBudget()517        applyAdoptSnapshotFrame(acc, emptyMap(), emptyMap(), "d".repeat(SNAPSHOT_FRAME_WIRE_CEILING), 10)518        assertEquals(SNAPSHOT_FRAME_WIRE_CEILING, acc.duressSlot!!.length)519520        val error = runCatching {521            applyAdoptSnapshotFrame(522                createAdoptSnapshotBudget(), emptyMap(), emptyMap(),523                "d".repeat(SNAPSHOT_FRAME_WIRE_CEILING + 1), 10,524            )525        }.exceptionOrNull()526        assertTrue(error is IllegalStateException)527        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"))528    }529530    @Test531    fun `V7 non-integer tombstone JSON rejects before merge`() = runBlocking {532        // Kotlin's Long type cannot hold NaN/Infinity, so hostile values die533        // one layer earlier than on the browser: the strict JSON decode fails534        // and the frame is rejected before any merge. Same verdict as the535        // browser's V7 (reject), enforced at the codec boundary.536        for (hostileValue in listOf("1.5", "99999999999999999999999", "NaN")) {537            val (source, receiver) = memoryPair()538            val hostile = """{"type":"ADOPT_SNAPSHOT","chunk_index":0,"chunk_count":1,"entries":{},"tombstones":{"dead":$hostileValue}}"""539            source.send(SyncPadding.pad(hostile.encodeToByteArray()))540            val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()541            assertTrue("value $hostileValue must reject", error is IllegalStateException)542            assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_INVALID_FRAME"))543        }544    }545546    @Test547    fun `V8 high-count low-byte snapshot round-trips 25000 tiny entries`() = runBlocking {548        val (source, receiver) = memoryPair()549        val entries = (0 until MAX_ADOPT_SNAPSHOT_ENTRIES).associate { "e$it" to "v" }550        sendVaultSnapshot(source, entries, emptyMap())551        val snapshot = receiveVaultSnapshot(receiver)552        assertEquals(MAX_ADOPT_SNAPSHOT_ENTRIES, snapshot.entries.size)553    }554555    @Test556    fun `V9 low-count high-byte stream over the aggregate rejects with no partial result`() = runBlocking {557        // 16 envelopes of ~674 KiB each: every frame fits the 768 KiB wire558        // ceiling, but the ~10.8 MiB total exceeds the 10 MiB aggregate.559        val (source, receiver) = memoryPair()560        val envelope = "c".repeat(690_000)561        val chunks = 16562        // Channels buffer UNLIMITED, so all frames can be queued before the563        // receiver starts — sequential style matches the rest of this file.564        for (i in 0 until chunks) {565            sendSnapshotFrame(source, AdoptionFrame.Snapshot(566                chunkIndex = i,567                chunkCount = chunks,568                entries = mapOf("big-$i" to envelope),569                tombstones = emptyMap(),570            ))571        }572        val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()573        assertTrue(error is IllegalStateException)574        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))575    }576577    @Test578    fun `V10 over-budget sender preflights with zero frames sent`() = runBlocking {579        val (source, _) = memoryPair()580        var frames = 0581        val countingSource = object : PairingTransport {582            override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }583            override suspend fun receive(): ByteArray = source.receive()584            override suspend fun close() {}585        }586        val envelope = "c".repeat(690_000)587        val entries = (0 until 16).associate { "big-$it" to envelope }588        val error = runCatching {589            sendVaultSnapshot(countingSource, entries, emptyMap())590        }.exceptionOrNull()591        assertTrue(error is IllegalStateException)592        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))593        assertEquals(0, frames)594    }595596    @Test597    fun `V11 duress bytes count against the aggregate budget`() {598        val acc = createAdoptSnapshotBudget()599        acc.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100600        // A 200-byte duress slot does not fit the remaining 100-byte budget.601        val error = runCatching {602            applyAdoptSnapshotFrame(acc, emptyMap(), emptyMap(), "d".repeat(200), 200)603        }.exceptionOrNull()604        assertTrue(error is IllegalStateException)605        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))606    }607}