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.Test9101112131415class 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 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 183 184 185 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 263 264 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 271 272 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 285 286 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 312313 sendVaultSnapshot(probe, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)314315 assertEquals(2, observedRawSizes.size)316 assertTrue(observedWireSizes.all { it == SyncPadding.bucket(it) })317 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 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 332 333 org.junit.Assert.assertTrue(334 "combined raw ${observedRawSizes[0] + observedRawSizes[1]} should exceed ceiling $ceiling",335 observedRawSizes[0] + observedRawSizes[1] > ceiling336 )337 }338339 340 341 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 347 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 362 363 364 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 379 380 381 382 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 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 405 406 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 430 431 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 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 533 534 535 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 558 559 val (source, receiver) = memoryPair()560 val envelope = "c".repeat(690_000)561 val chunks = 16562 563 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 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}