VaultAdoptSnapshotTest.kt

25.4 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 fun memoryPair(): Pair<PairingTransport, PairingTransport> {18        val a2b = Channel<ByteArray>(Channel.UNLIMITED)19        val b2a = Channel<ByteArray>(Channel.UNLIMITED)20        val a = object : PairingTransport {21            override suspend fun send(msg: ByteArray) { a2b.send(msg) }22            override suspend fun receive(): ByteArray = b2a.receive()23            override suspend fun close() {}24        }25        val b = object : PairingTransport {26            override suspend fun send(msg: ByteArray) { b2a.send(msg) }27            override suspend fun receive(): ByteArray = a2b.receive()28            override suspend fun close() {}29        }30        return a to b31    }3233    @Test34    fun `round-trips an empty vault`() = runBlocking {35        val (source, receiver) = memoryPair()36        sendVaultSnapshot(source, emptyMap(), emptyMap())37        val (entries, tombstones) = receiveVaultSnapshot(receiver)38        assertTrue(entries.isEmpty())39        assertTrue(tombstones.isEmpty())40    }4142    @Test43    fun `round-trips 50 entries and 5 tombstones in a single chunk`() = runBlocking {44        val (source, receiver) = memoryPair()45        val entries = (0 until 50).associate { "id-$it" to "cipher-$it" }46        val tombstones = (0 until 5).associate { "dead-$it" to (1000L + it) }47        sendVaultSnapshot(source, entries, tombstones)48        val (receivedEntries, receivedTombstones) = receiveVaultSnapshot(receiver)49        assertEquals(entries, receivedEntries)50        assertEquals(tombstones, receivedTombstones)51    }5253    @Test54    fun `splits a large vault into 3+ chunks and reassembles`() = runBlocking {55        val (source, receiver) = memoryPair()56        var frames = 057        val countingSource = object : PairingTransport {58            override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }59            override suspend fun receive(): ByteArray = source.receive()60            override suspend fun close() {}61        }62        val big = "x".repeat(200 * 1024)63        val entries = (0 until 5).associate { "id-$it" to big }64        val tombstones = mapOf("dead-0" to 42L)65        sendVaultSnapshot(countingSource, entries, tombstones)66        val (receivedEntries, receivedTombstones) = receiveVaultSnapshot(receiver)67        assertTrue("expected 3+ chunks, got $frames", frames >= 3)68        assertEquals(entries, receivedEntries)69        assertEquals(tombstones, receivedTombstones)70    }7172    @Test73    fun `rejects out-of-order chunks`() = runBlocking {74        val (source, receiver) = memoryPair()75        sendFrame(source, AdoptionFrame.Snapshot(76            chunkIndex = 1,77            chunkCount = 2,78            entries = mapOf("a" to "x"),79            tombstones = emptyMap(),80        ))81        val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()82        assertTrue(error is IllegalStateException)83        assertTrue(error!!.message!!.contains("OUT_OF_ORDER"))84    }8586    @Test87    fun `rejects chunk_count exceeding MAX_SNAPSHOT_CHUNKS`() = runBlocking {88        val (source, receiver) = memoryPair()89        sendFrame(source, AdoptionFrame.Snapshot(90            chunkIndex = 0,91            chunkCount = MAX_SNAPSHOT_CHUNKS + 1,92            entries = emptyMap(),93            tombstones = emptyMap(),94        ))95        val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()96        assertTrue(error is IllegalStateException)97        assertTrue(error!!.message!!.contains("TOO_MANY_CHUNKS"))98    }99100    @Test101    fun `validates ADOPT_SNAPSHOT frames`() {102        val valid = AdoptionFrame.Snapshot(103            chunkIndex = 0, chunkCount = 1,104            entries = mapOf("a" to "x"), tombstones = emptyMap(),105        )106        assertTrue(isAdoptSnapshot(valid))107        assertTrue(!isAdoptSnapshot(null))108        assertTrue(!isAdoptSnapshot(AdoptionFrame.Complete))109    }110111    @Test112    fun `snapshot_capable serializes in ADOPT_REQUEST`() {113        val request = AdoptionFrame.Request(114            sessionRequestId = "req-1",115            deviceName = "test",116            deviceType = "extension",117            snapshotCapable = true,118        )119        val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), request)120        assertTrue(json.contains("\"snapshot_capable\":true"))121        // Absent when null (encodeDefaults = false)122        val noFlag = AdoptionFrame.Request(123            sessionRequestId = "req-2",124            deviceName = "test",125            deviceType = "extension",126        )127        val jsonNoFlag = AdoptJson.encodeToString(AdoptionFrame.serializer(), noFlag)128        assertTrue(!jsonNoFlag.contains("snapshot_capable"))129    }130131    @Test132    fun `rejects raw frame exceeding wire ceiling`() = runBlocking {133        // H-09: a hostile source could send an arbitrarily large frame to exhaust134        // receiver memory before JSON parsing. receiveVaultSnapshot checks the raw135        // byte length against SNAPSHOT_FRAME_WIRE_CEILING (512 KiB + 256 KiB = 768 KiB)136        // and throws before attempting deserialization.137        val ceiling = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024138        val oversized = ByteArray(ceiling + 1)139        val channel = object : PairingTransport {140            override suspend fun send(msg: ByteArray) {}141            override suspend fun receive(): ByteArray = oversized142            override suspend fun close() {}143        }144        val error = runCatching { receiveVaultSnapshot(channel) }.exceptionOrNull()145        assertTrue(error is IllegalStateException)146        assertTrue(error!!.message!!.contains("CHUNK_TOO_LARGE"))147    }148149    @Test150    fun `rejects chunk_count mismatch on subsequent frame`() = runBlocking {151        val (source, receiver) = memoryPair()152        sendFrame(source, AdoptionFrame.Snapshot(153            chunkIndex = 0,154            chunkCount = 2,155            entries = mapOf("a" to "x"),156            tombstones = emptyMap(),157        ))158        sendFrame(source, AdoptionFrame.Snapshot(159            chunkIndex = 1,160            chunkCount = 3,161            entries = mapOf("b" to "y"),162            tombstones = emptyMap(),163        ))164        val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()165        assertTrue(error is IllegalStateException)166        assertTrue(error!!.message!!.contains("CHUNK_COUNT_MISMATCH"))167    }168169    @Test170    fun `round-trips duress_slot on final chunk`() = runBlocking {171        val (source, receiver) = memoryPair()172        val entries = mapOf("id-0" to "cipher-0")173        val tombstones = mapOf("dead-0" to 42L)174        val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""175        sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson)176        val snapshot = receiveVaultSnapshot(receiver)177        assertEquals(entries, snapshot.entries)178        assertEquals(tombstones, snapshot.tombstones)179        assertEquals(duressJson, snapshot.duressSlot)180    }181182    @Test183    fun `duress_slot absent returns null`() = runBlocking {184        val (source, receiver) = memoryPair()185        sendVaultSnapshot(source, mapOf("id-0" to "cipher-0"), emptyMap())186        val snapshot = receiveVaultSnapshot(receiver)187        assertNull(snapshot.duressSlot)188    }189190    @Test191    fun `duress_slot rides final chunk only in multi-chunk transfer`() = runBlocking {192        val (source, receiver) = memoryPair()193        val big = "x".repeat(200 * 1024)194        val entries = (0 until 5).associate { "id-$it" to big }195        val tombstones = mapOf("dead-0" to 42L)196        val duressJson = """{"version":1,"salt":"ff","nonce":"ee","ciphertext":"dd","tag":"cc"}"""197        sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson)198        val snapshot = receiveVaultSnapshot(receiver)199        assertEquals(entries, snapshot.entries)200        assertEquals(tombstones, snapshot.tombstones)201        assertEquals(duressJson, snapshot.duressSlot)202    }203204    @Test205    fun `old frame without duress_slot deserializes`() {206        val oldJson = """{"type":"ADOPT_SNAPSHOT","chunk_index":0,"chunk_count":1,"entries":{"a":"x"},"tombstones":{}}"""207        val frame = runCatching {208            AdoptJson.decodeFromString(AdoptionFrame.serializer(), oldJson)209        }.getOrNull()210        assertTrue(isAdoptSnapshot(frame))211        assertTrue((frame as AdoptionFrame.Snapshot).duressSlot == null)212    }213214    // Mirrors browser v4.6.4 T-A33: post-fix regression. With duress on215    // its own frame after the entries chunks, a >768 KiB combined payload216    // round-trips intact; pre-fix this threw ADOPT_SNAPSHOT_CHUNK_TOO_LARGE.217    @Test218    fun `frame ceiling 768KiB duress on separate frame round-trips`() = runBlocking {219        val (source, receiver) = memoryPair()220        val entries = mapOf("id-0" to "x".repeat(450 * 1024))221        val tombstones = mapOf("dead-0" to 1L)222        // Build a sealed-duress JSON literal with a 320 KiB ciphertext field223        // so combined raw payload (450 KiB entry + ~320 KiB duress + envelope)224        // crosses the 768 KiB SNAPSHOT_FRAME_WIRE_CEILING on the legacy wire.225        val duressJson = ("""{"version":1,"salt":"${"a".repeat(16)}",""" +226            """"nonce":"${"b".repeat(12)}",""" +227            """"ciphertext":"${"c".repeat(320 * 1024)}",""" +228            """"tag":"${"d".repeat(16)}"}""")229        sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson)230        val snapshot = receiveVaultSnapshot(receiver)231        assertEquals(entries, snapshot.entries)232        assertEquals(tombstones, snapshot.tombstones)233        assertEquals(duressJson, snapshot.duressSlot)234    }235236    // Mirrors browser v4.6.4 T-A34: structural proof that the duress wire237    // change is real. With >768 KiB combined, intermediate frames each stay238    // under the ceiling and exactly two frames are sent (entries + duress).239    @Test240    fun `intermediate frame sizes stay under wire ceiling when duress rides its own frame`() = runBlocking {241        val (source, _) = memoryPair()242        val observedRawSizes = mutableListOf<Int>()243        val observedHasDuress = mutableListOf<Boolean>()244        val observedEntriesSize = mutableListOf<Int>()245        val probe = object : PairingTransport {246            override suspend fun send(msg: ByteArray) {247                observedRawSizes.add(msg.size)248                val parsed = runCatching {249                    AdoptJson.decodeFromString(AdoptionFrame.Snapshot.serializer(), String(msg, Charsets.UTF_8))250                }.getOrNull()251                observedHasDuress.add(parsed?.duressSlot != null)252                observedEntriesSize.add(parsed?.entries?.size ?: 0)253                source.send(msg)254            }255            override suspend fun receive(): ByteArray = source.receive()256            override suspend fun close() {}257        }258        val entries = mapOf("id-0" to "x".repeat(450 * 1024))259        val duressJson = "y".repeat(320 * 1024)260        val ceiling = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024 // SNAPSHOT_FRAME_WIRE_CEILING261262        sendVaultSnapshot(probe, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)263264        assertEquals(2, observedRawSizes.size)265        // Entries frame carries entries, no duress, under ceiling.266        assertEquals(false, observedHasDuress[0])267        assertEquals(1, observedEntriesSize[0])268        org.junit.Assert.assertTrue(269            "entries frame ${observedRawSizes[0]} should be < ceiling $ceiling",270            observedRawSizes[0] < ceiling271        )272        // Duress frame carries duress, no entries, under ceiling.273        assertEquals(true, observedHasDuress[1])274        assertEquals(0, observedEntriesSize[1])275        org.junit.Assert.assertTrue(276            "duress frame ${observedRawSizes[1]} should be < ceiling $ceiling",277            observedRawSizes[1] < ceiling278        )279        // The point of the split: combined raw exceeds what a single frame280        // was permitted to carry on the old wire.281        org.junit.Assert.assertTrue(282            "combined raw ${observedRawSizes[0] + observedRawSizes[1]} should exceed ceiling $ceiling",283            observedRawSizes[0] + observedRawSizes[1] > ceiling284        )285    }286287    // Mirrors browser v4.6.4 T-A35: backward compat with pre-v2.9.9 senders288    // (duress on entries final chunk). The receiver merges per chunk so both289    // shapes decode.290    @Test291    fun `legacy duress on entries final chunk still decodes`() = runBlocking {292        val (source, receiver) = memoryPair()293        val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""294        // Manually construct the legacy v2.9.8 wire shape: a single chunk295        // with entries, tombstones AND duress_slot all on it.296        sendFrame(source, AdoptionFrame.Snapshot(297            chunkIndex = 0,298            chunkCount = 1,299            entries = mapOf("id-0" to "ciphertext"),300            tombstones = mapOf("dead-0" to 42L),301            duressSlot = duressJson,302        ))303        val snapshot = receiveVaultSnapshot(receiver)304        assertEquals(mapOf("id-0" to "ciphertext"), snapshot.entries)305        assertEquals(mapOf("dead-0" to 42L), snapshot.tombstones)306        assertEquals(duressJson, snapshot.duressSlot)307    }308309    // Mirrors browser T-A36: cap boundary inclusive. With 199 entries and a310    // duress slot, the sender fits everything within MAX_SNAPSHOT_CHUNKS = 200311    // (199 entries + 1 duress = 200 frames). Entries are 40 KiB so the ~8 MiB312    // total also fits the PB-26-206 10 MiB aggregate budget.313    @Test314    fun `duress is reserved a frame so 199 entries plus duress fits in MAX_SNAPSHOT_CHUNKS`() = runBlocking {315        val (source, receiver) = memoryPair()316        val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""317        val big = "x".repeat(40 * 1024)318        val entries = (0 until 199).associate { "id-$it" to big }319        sendVaultSnapshot(source, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)320        val snapshot = receiveVaultSnapshot(receiver)321        assertEquals(entries, snapshot.entries)322        assertEquals(mapOf("dead-0" to 1L), snapshot.tombstones)323        assertEquals(duressJson, snapshot.duressSlot)324    }325326    // Mirrors browser T-A37: PB-26-206 supersedes the old chunk-cap327    // truncation. An over-budget snapshot is never partially sent: the sender328    // preflights with the same aggregate helper the receiver enforces and329    // throws before the first frame, so the caller falls through to the330    // regular sync pipeline instead of sending a truncated snapshot.331    @Test332    fun `over-budget snapshot preflights with zero frames sent`() = runBlocking {333        val (source, _) = memoryPair()334        var frames = 0335        val countingSource = object : PairingTransport {336            override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }337            override suspend fun receive(): ByteArray = source.receive()338            override suspend fun close() {}339        }340        val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""341        // 200 entries × 60 KiB ≈ 12 MiB > 10 MiB aggregate budget.342        val big = "x".repeat(60 * 1024)343        val entries = (0 until 200).associate { "id-$it" to big }344        val error = runCatching {345            sendVaultSnapshot(countingSource, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)346        }.exceptionOrNull()347        assertTrue(error is IllegalStateException)348        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))349        assertEquals(0, frames)350    }351352    // Cap-pre-check guardrail: a duress payload that itself exceeds the wire353    // ceiling throws ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE before any frame is354    // sent — adoption never silently loses duress.355    @Test356    fun `duress larger than wire ceiling throws loudly before sending`() = runBlocking {357        val (source, _) = memoryPair()358        var frames = 0359        val countingSource = object : PairingTransport {360            override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }361            override suspend fun receive(): ByteArray = source.receive()362            override suspend fun close() {}363        }364        val ceiling = 768 * 1024365        val emptyWireSize = AdoptJson.encodeToString(366            AdoptionFrame.serializer(),367            AdoptionFrame.Snapshot(1, 2, emptyMap(), emptyMap(), ""),368        ).encodeToByteArray().size369        val duressJson = "x".repeat(ceiling - emptyWireSize + 1)370        val error = runCatching {371            sendVaultSnapshot(countingSource, mapOf("id-0" to "small"), emptyMap(), duressSlot = duressJson)372        }.exceptionOrNull()373        assertTrue(error is SnapshotDuressFrameTooLargeException)374        assertEquals(0, frames)375    }376377    // PB-26-206 aggregate budget vectors V1..V11. The same wire shapes and378    // verdicts are asserted in the browser's vault-adopt-snapshot.test.ts —379    // both clients must agree on accept/reject for every vector.380381    @Test382    fun `V1 frame bytes at the aggregate limit pass, limit plus one rejects before merge`() {383        val atLimit = createAdoptSnapshotBudget()384        atLimit.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100385        applyAdoptSnapshotFrame(atLimit, mapOf("a" to "x"), emptyMap(), null, 100)386        assertEquals(MAX_ADOPT_SNAPSHOT_BYTES, atLimit.bytes)387388        val over = createAdoptSnapshotBudget()389        over.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100390        val error = runCatching {391            applyAdoptSnapshotFrame(over, mapOf("a" to "x"), emptyMap(), null, 101)392        }.exceptionOrNull()393        assertTrue(error is IllegalStateException)394        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))395        // Rejected before merge: no partial adopted state escapes.396        assertTrue(over.entries.isEmpty())397    }398399    @Test400    fun `V2 25000 entries pass, the 25001st rejects`() {401        val entries = (0 until MAX_ADOPT_SNAPSHOT_ENTRIES).associate { "e$it" to "v" }402        val acc = createAdoptSnapshotBudget()403        applyAdoptSnapshotFrame(acc, entries, emptyMap(), null, 100)404        assertEquals(MAX_ADOPT_SNAPSHOT_ENTRIES, acc.entryCount)405        val error = runCatching {406            applyAdoptSnapshotFrame(acc, mapOf("oneMore" to "v"), emptyMap(), null, 1)407        }.exceptionOrNull()408        assertTrue(error is IllegalStateException)409        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES"))410        assertNull(acc.entries["oneMore"])411    }412413    @Test414    fun `V3 25000 tombstones pass, the 25001st rejects`() {415        val tombstones = (0 until MAX_ADOPT_SNAPSHOT_TOMBSTONES).associate { "dead-$it" to (it + 1L) }416        val acc = createAdoptSnapshotBudget()417        applyAdoptSnapshotFrame(acc, emptyMap(), tombstones, null, 100)418        assertEquals(MAX_ADOPT_SNAPSHOT_TOMBSTONES, acc.tombstoneCount)419        val error = runCatching {420            applyAdoptSnapshotFrame(acc, emptyMap(), mapOf("oneMore" to 1L), null, 1)421        }.exceptionOrNull()422        assertTrue(error is IllegalStateException)423        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES"))424        assertNull(acc.tombstones["oneMore"])425    }426427    @Test428    fun `V4 entry and tombstone IDs of 512 chars pass, 513 reject`() {429        val ok = "k".repeat(MAX_ADOPT_SNAPSHOT_ID_CHARS)430        val acc = createAdoptSnapshotBudget()431        applyAdoptSnapshotFrame(acc, mapOf(ok to "v"), mapOf(ok to 1L), null, 10)432        assertEquals("v", acc.entries[ok])433434        val tooLong = "k".repeat(MAX_ADOPT_SNAPSHOT_ID_CHARS + 1)435        val entryError = runCatching {436            applyAdoptSnapshotFrame(createAdoptSnapshotBudget(), mapOf(tooLong to "v"), emptyMap(), null, 10)437        }.exceptionOrNull()438        assertTrue(entryError is IllegalStateException)439        assertTrue(entryError!!.message!!.contains("ADOPT_SNAPSHOT_ID_TOO_LONG"))440        val tombstoneError = runCatching {441            applyAdoptSnapshotFrame(createAdoptSnapshotBudget(), emptyMap(), mapOf(tooLong to 1L), null, 10)442        }.exceptionOrNull()443        assertTrue(tombstoneError is IllegalStateException)444        assertTrue(tombstoneError!!.message!!.contains("ADOPT_SNAPSHOT_ID_TOO_LONG"))445    }446447    @Test448    fun `V5 envelope at the base64 overhead ceiling passes, plus one rejects`() {449        val atLimit = "c".repeat(MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES)450        val acc = createAdoptSnapshotBudget()451        applyAdoptSnapshotFrame(acc, mapOf("e1" to atLimit), emptyMap(), null, 10)452        assertEquals(atLimit, acc.entries["e1"])453454        val over = "c".repeat(MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES + 1)455        val error = runCatching {456            applyAdoptSnapshotFrame(createAdoptSnapshotBudget(), mapOf("e1" to over), emptyMap(), null, 10)457        }.exceptionOrNull()458        assertTrue(error is IllegalStateException)459        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE"))460    }461462    @Test463    fun `V6 duress slot at the wire frame ceiling passes, plus one rejects`() {464        val acc = createAdoptSnapshotBudget()465        applyAdoptSnapshotFrame(acc, emptyMap(), emptyMap(), "d".repeat(SNAPSHOT_FRAME_WIRE_CEILING), 10)466        assertEquals(SNAPSHOT_FRAME_WIRE_CEILING, acc.duressSlot!!.length)467468        val error = runCatching {469            applyAdoptSnapshotFrame(470                createAdoptSnapshotBudget(), emptyMap(), emptyMap(),471                "d".repeat(SNAPSHOT_FRAME_WIRE_CEILING + 1), 10,472            )473        }.exceptionOrNull()474        assertTrue(error is IllegalStateException)475        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"))476    }477478    @Test479    fun `V7 non-integer tombstone JSON rejects before merge`() = runBlocking {480        // Kotlin's Long type cannot hold NaN/Infinity, so hostile values die481        // one layer earlier than on the browser: the strict JSON decode fails482        // and the frame is rejected before any merge. Same verdict as the483        // browser's V7 (reject), enforced at the codec boundary.484        for (hostileValue in listOf("1.5", "99999999999999999999999", "NaN")) {485            val (source, receiver) = memoryPair()486            val hostile = """{"type":"ADOPT_SNAPSHOT","chunk_index":0,"chunk_count":1,"entries":{},"tombstones":{"dead":$hostileValue}}"""487            source.send(hostile.encodeToByteArray())488            val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()489            assertTrue("value $hostileValue must reject", error is IllegalStateException)490            assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_INVALID_FRAME"))491        }492    }493494    @Test495    fun `V8 high-count low-byte snapshot round-trips 25000 tiny entries`() = runBlocking {496        val (source, receiver) = memoryPair()497        val entries = (0 until MAX_ADOPT_SNAPSHOT_ENTRIES).associate { "e$it" to "v" }498        sendVaultSnapshot(source, entries, emptyMap())499        val snapshot = receiveVaultSnapshot(receiver)500        assertEquals(MAX_ADOPT_SNAPSHOT_ENTRIES, snapshot.entries.size)501    }502503    @Test504    fun `V9 low-count high-byte stream over the aggregate rejects with no partial result`() = runBlocking {505        // 16 envelopes of ~674 KiB each: every frame fits the 768 KiB wire506        // ceiling, but the ~10.8 MiB total exceeds the 10 MiB aggregate.507        val (source, receiver) = memoryPair()508        val envelope = "c".repeat(690_000)509        val chunks = 16510        // Channels buffer UNLIMITED, so all frames can be queued before the511        // receiver starts — sequential style matches the rest of this file.512        for (i in 0 until chunks) {513            sendFrame(source, AdoptionFrame.Snapshot(514                chunkIndex = i,515                chunkCount = chunks,516                entries = mapOf("big-$i" to envelope),517                tombstones = emptyMap(),518            ))519        }520        val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()521        assertTrue(error is IllegalStateException)522        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))523    }524525    @Test526    fun `V10 over-budget sender preflights with zero frames sent`() = runBlocking {527        val (source, _) = memoryPair()528        var frames = 0529        val countingSource = object : PairingTransport {530            override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }531            override suspend fun receive(): ByteArray = source.receive()532            override suspend fun close() {}533        }534        val envelope = "c".repeat(690_000)535        val entries = (0 until 16).associate { "big-$it" to envelope }536        val error = runCatching {537            sendVaultSnapshot(countingSource, entries, emptyMap())538        }.exceptionOrNull()539        assertTrue(error is IllegalStateException)540        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))541        assertEquals(0, frames)542    }543544    @Test545    fun `V11 duress bytes count against the aggregate budget`() {546        val acc = createAdoptSnapshotBudget()547        acc.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100548        // A 200-byte duress slot does not fit the remaining 100-byte budget.549        val error = runCatching {550            applyAdoptSnapshotFrame(acc, emptyMap(), emptyMap(), "d".repeat(200), 200)551        }.exceptionOrNull()552        assertTrue(error is IllegalStateException)553        assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))554    }555}