package com.peachpasswords.sync import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** * Inline vault snapshot protocol parity with the browser * `vault-adopt-snapshot.test.ts`. Round-trip, chunking, and validation * tests for the ADOPT_SNAPSHOT / ADOPT_SNAPSHOT_APPLIED frames. */ class VaultAdoptSnapshotTest { private suspend fun sendSnapshotFrame(channel: PairingTransport, frame: AdoptionFrame.Snapshot) { val raw = AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).encodeToByteArray() channel.send(SyncPadding.pad(raw)) } @Test fun `PB-26-706 snapshot frames use the shared sync padding wire format`() = runBlocking { val sent = mutableListOf() val channel = object : PairingTransport { override suspend fun send(msg: ByteArray) { sent += msg } override suspend fun receive(): ByteArray = error("unused") override suspend fun close() {} } sendVaultSnapshot(channel, mapOf("id-0" to "ciphertext"), emptyMap()) assertEquals(2, sent.size) sent.forEach { wire -> assertEquals(SyncPadding.bucket(wire.size), wire.size) assertTrue(String(wire, Charsets.UTF_8).first() != '{') assertTrue(String(SyncPadding.unpad(wire), Charsets.UTF_8).startsWith("{\"type\":\"ADOPT_SNAPSHOT\"")) } val supplemental = AdoptJson.decodeFromString( AdoptionFrame.Snapshot.serializer(), String(SyncPadding.unpad(sent.last()), Charsets.UTF_8), ) assertTrue(supplemental.entries.isEmpty()) assertTrue(supplemental.tombstones.isEmpty()) assertNull(supplemental.duressSlot) } @Test fun `duress and no-duress snapshots use the same frame count`() = runBlocking { suspend fun frameCount(duressSlot: String?): Int { val sent = mutableListOf() val channel = object : PairingTransport { override suspend fun send(msg: ByteArray) { sent += msg } override suspend fun receive(): ByteArray = error("unused") override suspend fun close() {} } sendVaultSnapshot(channel, mapOf("id-0" to "ciphertext"), emptyMap(), duressSlot) return sent.size } val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}""" assertEquals(frameCount(null), frameCount(duressJson)) } private fun memoryPair(): Pair { val a2b = Channel(Channel.UNLIMITED) val b2a = Channel(Channel.UNLIMITED) val a = object : PairingTransport { override suspend fun send(msg: ByteArray) { a2b.send(msg) } override suspend fun receive(): ByteArray = b2a.receive() override suspend fun close() {} } val b = object : PairingTransport { override suspend fun send(msg: ByteArray) { b2a.send(msg) } override suspend fun receive(): ByteArray = a2b.receive() override suspend fun close() {} } return a to b } @Test fun `round-trips an empty vault`() = runBlocking { val (source, receiver) = memoryPair() sendVaultSnapshot(source, emptyMap(), emptyMap()) val (entries, tombstones) = receiveVaultSnapshot(receiver) assertTrue(entries.isEmpty()) assertTrue(tombstones.isEmpty()) } @Test fun `round-trips 50 entries and 5 tombstones in a single chunk`() = runBlocking { val (source, receiver) = memoryPair() val entries = (0 until 50).associate { "id-$it" to "cipher-$it" } val tombstones = (0 until 5).associate { "dead-$it" to (1000L + it) } sendVaultSnapshot(source, entries, tombstones) val (receivedEntries, receivedTombstones) = receiveVaultSnapshot(receiver) assertEquals(entries, receivedEntries) assertEquals(tombstones, receivedTombstones) } @Test fun `splits a large vault into 3+ chunks and reassembles`() = runBlocking { val (source, receiver) = memoryPair() var frames = 0 val countingSource = object : PairingTransport { override suspend fun send(msg: ByteArray) { frames++; source.send(msg) } override suspend fun receive(): ByteArray = source.receive() override suspend fun close() {} } val big = "x".repeat(200 * 1024) val entries = (0 until 5).associate { "id-$it" to big } val tombstones = mapOf("dead-0" to 42L) sendVaultSnapshot(countingSource, entries, tombstones) val (receivedEntries, receivedTombstones) = receiveVaultSnapshot(receiver) assertTrue("expected 3+ chunks, got $frames", frames >= 3) assertEquals(entries, receivedEntries) assertEquals(tombstones, receivedTombstones) } @Test fun `rejects out-of-order chunks`() = runBlocking { val (source, receiver) = memoryPair() sendSnapshotFrame(source, AdoptionFrame.Snapshot( chunkIndex = 1, chunkCount = 2, entries = mapOf("a" to "x"), tombstones = emptyMap(), )) val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull() assertTrue(error is IllegalStateException) assertTrue(error!!.message!!.contains("OUT_OF_ORDER")) } @Test fun `rejects chunk_count exceeding MAX_SNAPSHOT_CHUNKS`() = runBlocking { val (source, receiver) = memoryPair() sendSnapshotFrame(source, AdoptionFrame.Snapshot( chunkIndex = 0, chunkCount = MAX_SNAPSHOT_CHUNKS + 1, entries = emptyMap(), tombstones = emptyMap(), )) val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull() assertTrue(error is IllegalStateException) assertTrue(error!!.message!!.contains("TOO_MANY_CHUNKS")) } @Test fun `validates ADOPT_SNAPSHOT frames`() { val valid = AdoptionFrame.Snapshot( chunkIndex = 0, chunkCount = 1, entries = mapOf("a" to "x"), tombstones = emptyMap(), ) assertTrue(isAdoptSnapshot(valid)) assertTrue(!isAdoptSnapshot(null)) assertTrue(!isAdoptSnapshot(AdoptionFrame.Complete)) } @Test fun `snapshot_capable serializes in ADOPT_REQUEST`() { val request = AdoptionFrame.Request( sessionRequestId = "req-1", deviceName = "test", deviceType = "extension", snapshotCapable = true, ) val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), request) assertTrue(json.contains("\"snapshot_capable\":true")) // Absent when null (encodeDefaults = false) val noFlag = AdoptionFrame.Request( sessionRequestId = "req-2", deviceName = "test", deviceType = "extension", ) val jsonNoFlag = AdoptJson.encodeToString(AdoptionFrame.serializer(), noFlag) assertTrue(!jsonNoFlag.contains("snapshot_capable")) } @Test fun `rejects padded frame exceeding wire ceiling`() = runBlocking { // H-09: a hostile source could send an arbitrarily large frame to exhaust // receiver memory before unpadding or JSON parsing. receiveVaultSnapshot // checks the padded wire length against the largest permitted sync bucket // and throws before attempting either operation. val oversized = ByteArray(MAX_PADDED_SNAPSHOT_FRAME_BYTES + 1) val channel = object : PairingTransport { override suspend fun send(msg: ByteArray) {} override suspend fun receive(): ByteArray = oversized override suspend fun close() {} } val error = runCatching { receiveVaultSnapshot(channel) }.exceptionOrNull() assertTrue(error is IllegalStateException) assertTrue(error!!.message!!.contains("CHUNK_TOO_LARGE")) } @Test fun `rejects chunk_count mismatch on subsequent frame`() = runBlocking { val (source, receiver) = memoryPair() sendSnapshotFrame(source, AdoptionFrame.Snapshot( chunkIndex = 0, chunkCount = 2, entries = mapOf("a" to "x"), tombstones = emptyMap(), )) sendSnapshotFrame(source, AdoptionFrame.Snapshot( chunkIndex = 1, chunkCount = 3, entries = mapOf("b" to "y"), tombstones = emptyMap(), )) val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull() assertTrue(error is IllegalStateException) assertTrue(error!!.message!!.contains("CHUNK_COUNT_MISMATCH")) } @Test fun `round-trips duress_slot on final chunk`() = runBlocking { val (source, receiver) = memoryPair() val entries = mapOf("id-0" to "cipher-0") val tombstones = mapOf("dead-0" to 42L) val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}""" sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson) val snapshot = receiveVaultSnapshot(receiver) assertEquals(entries, snapshot.entries) assertEquals(tombstones, snapshot.tombstones) assertEquals(duressJson, snapshot.duressSlot) } @Test fun `duress_slot absent returns null`() = runBlocking { val (source, receiver) = memoryPair() sendVaultSnapshot(source, mapOf("id-0" to "cipher-0"), emptyMap()) val snapshot = receiveVaultSnapshot(receiver) assertNull(snapshot.duressSlot) } @Test fun `duress_slot rides final chunk only in multi-chunk transfer`() = runBlocking { val (source, receiver) = memoryPair() val big = "x".repeat(200 * 1024) val entries = (0 until 5).associate { "id-$it" to big } val tombstones = mapOf("dead-0" to 42L) val duressJson = """{"version":1,"salt":"ff","nonce":"ee","ciphertext":"dd","tag":"cc"}""" sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson) val snapshot = receiveVaultSnapshot(receiver) assertEquals(entries, snapshot.entries) assertEquals(tombstones, snapshot.tombstones) assertEquals(duressJson, snapshot.duressSlot) } @Test fun `old frame without duress_slot deserializes`() { val oldJson = """{"type":"ADOPT_SNAPSHOT","chunk_index":0,"chunk_count":1,"entries":{"a":"x"},"tombstones":{}}""" val frame = runCatching { AdoptJson.decodeFromString(AdoptionFrame.serializer(), oldJson) }.getOrNull() assertTrue(isAdoptSnapshot(frame)) assertTrue((frame as AdoptionFrame.Snapshot).duressSlot == null) } // Mirrors browser v4.6.4 T-A33: post-fix regression. With duress on // its own frame after the entries chunks, a >768 KiB combined payload // round-trips intact; pre-fix this threw ADOPT_SNAPSHOT_CHUNK_TOO_LARGE. @Test fun `frame ceiling 768KiB duress on separate frame round-trips`() = runBlocking { val (source, receiver) = memoryPair() val entries = mapOf("id-0" to "x".repeat(450 * 1024)) val tombstones = mapOf("dead-0" to 1L) // Build a sealed-duress JSON literal with a 320 KiB ciphertext field // so combined raw payload (450 KiB entry + ~320 KiB duress + envelope) // crosses the 768 KiB SNAPSHOT_FRAME_WIRE_CEILING on the legacy wire. val duressJson = ("""{"version":1,"salt":"${"a".repeat(16)}",""" + """"nonce":"${"b".repeat(12)}",""" + """"ciphertext":"${"c".repeat(320 * 1024)}",""" + """"tag":"${"d".repeat(16)}"}""") sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson) val snapshot = receiveVaultSnapshot(receiver) assertEquals(entries, snapshot.entries) assertEquals(tombstones, snapshot.tombstones) assertEquals(duressJson, snapshot.duressSlot) } // Mirrors browser v4.6.4 T-A34: structural proof that the duress wire // change is real. With >768 KiB combined, intermediate frames each stay // under the ceiling and exactly two frames are sent (entries + duress). @Test fun `intermediate frame sizes stay under wire ceiling when duress rides its own frame`() = runBlocking { val (source, _) = memoryPair() val observedRawSizes = mutableListOf() val observedWireSizes = mutableListOf() val observedHasDuress = mutableListOf() val observedEntriesSize = mutableListOf() val probe = object : PairingTransport { override suspend fun send(msg: ByteArray) { observedWireSizes.add(msg.size) val raw = SyncPadding.unpad(msg) observedRawSizes.add(raw.size) val parsed = runCatching { AdoptJson.decodeFromString(AdoptionFrame.Snapshot.serializer(), String(raw, Charsets.UTF_8)) }.getOrNull() observedHasDuress.add(parsed?.duressSlot != null) observedEntriesSize.add(parsed?.entries?.size ?: 0) source.send(msg) } override suspend fun receive(): ByteArray = source.receive() override suspend fun close() {} } val entries = mapOf("id-0" to "x".repeat(450 * 1024)) val duressJson = "y".repeat(320 * 1024) val ceiling = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024 // SNAPSHOT_FRAME_WIRE_CEILING sendVaultSnapshot(probe, entries, mapOf("dead-0" to 1L), duressSlot = duressJson) assertEquals(2, observedRawSizes.size) assertTrue(observedWireSizes.all { it == SyncPadding.bucket(it) }) // Entries frame carries entries, no duress, under ceiling. assertEquals(false, observedHasDuress[0]) assertEquals(1, observedEntriesSize[0]) org.junit.Assert.assertTrue( "entries frame ${observedRawSizes[0]} should be < ceiling $ceiling", observedRawSizes[0] < ceiling ) // Duress frame carries duress, no entries, under ceiling. assertEquals(true, observedHasDuress[1]) assertEquals(0, observedEntriesSize[1]) org.junit.Assert.assertTrue( "duress frame ${observedRawSizes[1]} should be < ceiling $ceiling", observedRawSizes[1] < ceiling ) // The point of the split: combined raw exceeds what a single frame // was permitted to carry on the old wire. org.junit.Assert.assertTrue( "combined raw ${observedRawSizes[0] + observedRawSizes[1]} should exceed ceiling $ceiling", observedRawSizes[0] + observedRawSizes[1] > ceiling ) } // Mirrors browser v4.6.4 T-A35: backward compat with pre-v2.9.9 senders // (duress on entries final chunk). The receiver merges per chunk so both // shapes decode. @Test fun `legacy duress on entries final chunk still decodes`() = runBlocking { val (source, receiver) = memoryPair() val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}""" // Manually construct the legacy v2.9.8 wire shape: a single chunk // with entries, tombstones AND duress_slot all on it. sendSnapshotFrame(source, AdoptionFrame.Snapshot( chunkIndex = 0, chunkCount = 1, entries = mapOf("id-0" to "ciphertext"), tombstones = mapOf("dead-0" to 42L), duressSlot = duressJson, )) val snapshot = receiveVaultSnapshot(receiver) assertEquals(mapOf("id-0" to "ciphertext"), snapshot.entries) assertEquals(mapOf("dead-0" to 42L), snapshot.tombstones) assertEquals(duressJson, snapshot.duressSlot) } // Mirrors browser T-A36: cap boundary inclusive. With 199 entries and a // duress slot, the sender fits everything within MAX_SNAPSHOT_CHUNKS = 200 // (199 entries + 1 duress = 200 frames). Entries are 40 KiB so the ~8 MiB // total also fits the PB-26-206 10 MiB aggregate budget. @Test fun `duress is reserved a frame so 199 entries plus duress fits in MAX_SNAPSHOT_CHUNKS`() = runBlocking { val (source, receiver) = memoryPair() val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}""" val big = "x".repeat(40 * 1024) val entries = (0 until 199).associate { "id-$it" to big } sendVaultSnapshot(source, entries, mapOf("dead-0" to 1L), duressSlot = duressJson) val snapshot = receiveVaultSnapshot(receiver) assertEquals(entries, snapshot.entries) assertEquals(mapOf("dead-0" to 1L), snapshot.tombstones) assertEquals(duressJson, snapshot.duressSlot) } // Mirrors browser T-A37: PB-26-206 supersedes the old chunk-cap // truncation. An over-budget snapshot is never partially sent: the sender // preflights with the same aggregate helper the receiver enforces and // throws before the first frame, so the caller falls through to the // regular sync pipeline instead of sending a truncated snapshot. @Test fun `over-budget snapshot preflights with zero frames sent`() = runBlocking { val (source, _) = memoryPair() var frames = 0 val countingSource = object : PairingTransport { override suspend fun send(msg: ByteArray) { frames++; source.send(msg) } override suspend fun receive(): ByteArray = source.receive() override suspend fun close() {} } val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}""" // 200 entries × 60 KiB ≈ 12 MiB > 10 MiB aggregate budget. val big = "x".repeat(60 * 1024) val entries = (0 until 200).associate { "id-$it" to big } val error = runCatching { sendVaultSnapshot(countingSource, entries, mapOf("dead-0" to 1L), duressSlot = duressJson) }.exceptionOrNull() assertTrue(error is IllegalStateException) assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET")) assertEquals(0, frames) } // Cap-pre-check guardrail: a duress payload that itself exceeds the wire // ceiling throws ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE before any frame is // sent — adoption never silently loses duress. @Test fun `duress larger than wire ceiling throws loudly before sending`() = runBlocking { val (source, _) = memoryPair() var frames = 0 val countingSource = object : PairingTransport { override suspend fun send(msg: ByteArray) { frames++; source.send(msg) } override suspend fun receive(): ByteArray = source.receive() override suspend fun close() {} } val ceiling = 768 * 1024 val emptyWireSize = AdoptJson.encodeToString( AdoptionFrame.serializer(), AdoptionFrame.Snapshot(1, 2, emptyMap(), emptyMap(), ""), ).encodeToByteArray().size val duressJson = "x".repeat(ceiling - emptyWireSize + 1) val error = runCatching { sendVaultSnapshot(countingSource, mapOf("id-0" to "small"), emptyMap(), duressSlot = duressJson) }.exceptionOrNull() assertTrue(error is SnapshotDuressFrameTooLargeException) assertEquals(0, frames) } // PB-26-206 aggregate budget vectors V1..V11. The same wire shapes and // verdicts are asserted in the browser's vault-adopt-snapshot.test.ts — // both clients must agree on accept/reject for every vector. @Test fun `V1 frame bytes at the aggregate limit pass, limit plus one rejects before merge`() { val atLimit = createAdoptSnapshotBudget() atLimit.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100 applyAdoptSnapshotFrame(atLimit, mapOf("a" to "x"), emptyMap(), null, 100) assertEquals(MAX_ADOPT_SNAPSHOT_BYTES, atLimit.bytes) val over = createAdoptSnapshotBudget() over.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100 val error = runCatching { applyAdoptSnapshotFrame(over, mapOf("a" to "x"), emptyMap(), null, 101) }.exceptionOrNull() assertTrue(error is IllegalStateException) assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET")) // Rejected before merge: no partial adopted state escapes. assertTrue(over.entries.isEmpty()) } @Test fun `V2 25000 entries pass, the 25001st rejects`() { val entries = (0 until MAX_ADOPT_SNAPSHOT_ENTRIES).associate { "e$it" to "v" } val acc = createAdoptSnapshotBudget() applyAdoptSnapshotFrame(acc, entries, emptyMap(), null, 100) assertEquals(MAX_ADOPT_SNAPSHOT_ENTRIES, acc.entryCount) val error = runCatching { applyAdoptSnapshotFrame(acc, mapOf("oneMore" to "v"), emptyMap(), null, 1) }.exceptionOrNull() assertTrue(error is IllegalStateException) assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES")) assertNull(acc.entries["oneMore"]) } @Test fun `V3 25000 tombstones pass, the 25001st rejects`() { val tombstones = (0 until MAX_ADOPT_SNAPSHOT_TOMBSTONES).associate { "dead-$it" to (it + 1L) } val acc = createAdoptSnapshotBudget() applyAdoptSnapshotFrame(acc, emptyMap(), tombstones, null, 100) assertEquals(MAX_ADOPT_SNAPSHOT_TOMBSTONES, acc.tombstoneCount) val error = runCatching { applyAdoptSnapshotFrame(acc, emptyMap(), mapOf("oneMore" to 1L), null, 1) }.exceptionOrNull() assertTrue(error is IllegalStateException) assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES")) assertNull(acc.tombstones["oneMore"]) } @Test fun `V4 entry and tombstone IDs of 512 chars pass, 513 reject`() { val ok = "k".repeat(MAX_ADOPT_SNAPSHOT_ID_CHARS) val acc = createAdoptSnapshotBudget() applyAdoptSnapshotFrame(acc, mapOf(ok to "v"), mapOf(ok to 1L), null, 10) assertEquals("v", acc.entries[ok]) val tooLong = "k".repeat(MAX_ADOPT_SNAPSHOT_ID_CHARS + 1) val entryError = runCatching { applyAdoptSnapshotFrame(createAdoptSnapshotBudget(), mapOf(tooLong to "v"), emptyMap(), null, 10) }.exceptionOrNull() assertTrue(entryError is IllegalStateException) assertTrue(entryError!!.message!!.contains("ADOPT_SNAPSHOT_ID_TOO_LONG")) val tombstoneError = runCatching { applyAdoptSnapshotFrame(createAdoptSnapshotBudget(), emptyMap(), mapOf(tooLong to 1L), null, 10) }.exceptionOrNull() assertTrue(tombstoneError is IllegalStateException) assertTrue(tombstoneError!!.message!!.contains("ADOPT_SNAPSHOT_ID_TOO_LONG")) } @Test fun `V5 envelope at the base64 overhead ceiling passes, plus one rejects`() { val atLimit = "c".repeat(MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) val acc = createAdoptSnapshotBudget() applyAdoptSnapshotFrame(acc, mapOf("e1" to atLimit), emptyMap(), null, 10) assertEquals(atLimit, acc.entries["e1"]) val over = "c".repeat(MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES + 1) val error = runCatching { applyAdoptSnapshotFrame(createAdoptSnapshotBudget(), mapOf("e1" to over), emptyMap(), null, 10) }.exceptionOrNull() assertTrue(error is IllegalStateException) assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE")) } @Test fun `V6 duress slot at the wire frame ceiling passes, plus one rejects`() { val acc = createAdoptSnapshotBudget() applyAdoptSnapshotFrame(acc, emptyMap(), emptyMap(), "d".repeat(SNAPSHOT_FRAME_WIRE_CEILING), 10) assertEquals(SNAPSHOT_FRAME_WIRE_CEILING, acc.duressSlot!!.length) val error = runCatching { applyAdoptSnapshotFrame( createAdoptSnapshotBudget(), emptyMap(), emptyMap(), "d".repeat(SNAPSHOT_FRAME_WIRE_CEILING + 1), 10, ) }.exceptionOrNull() assertTrue(error is IllegalStateException) assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")) } @Test fun `V7 non-integer tombstone JSON rejects before merge`() = runBlocking { // Kotlin's Long type cannot hold NaN/Infinity, so hostile values die // one layer earlier than on the browser: the strict JSON decode fails // and the frame is rejected before any merge. Same verdict as the // browser's V7 (reject), enforced at the codec boundary. for (hostileValue in listOf("1.5", "99999999999999999999999", "NaN")) { val (source, receiver) = memoryPair() val hostile = """{"type":"ADOPT_SNAPSHOT","chunk_index":0,"chunk_count":1,"entries":{},"tombstones":{"dead":$hostileValue}}""" source.send(SyncPadding.pad(hostile.encodeToByteArray())) val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull() assertTrue("value $hostileValue must reject", error is IllegalStateException) assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_INVALID_FRAME")) } } @Test fun `V8 high-count low-byte snapshot round-trips 25000 tiny entries`() = runBlocking { val (source, receiver) = memoryPair() val entries = (0 until MAX_ADOPT_SNAPSHOT_ENTRIES).associate { "e$it" to "v" } sendVaultSnapshot(source, entries, emptyMap()) val snapshot = receiveVaultSnapshot(receiver) assertEquals(MAX_ADOPT_SNAPSHOT_ENTRIES, snapshot.entries.size) } @Test fun `V9 low-count high-byte stream over the aggregate rejects with no partial result`() = runBlocking { // 16 envelopes of ~674 KiB each: every frame fits the 768 KiB wire // ceiling, but the ~10.8 MiB total exceeds the 10 MiB aggregate. val (source, receiver) = memoryPair() val envelope = "c".repeat(690_000) val chunks = 16 // Channels buffer UNLIMITED, so all frames can be queued before the // receiver starts — sequential style matches the rest of this file. for (i in 0 until chunks) { sendSnapshotFrame(source, AdoptionFrame.Snapshot( chunkIndex = i, chunkCount = chunks, entries = mapOf("big-$i" to envelope), tombstones = emptyMap(), )) } val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull() assertTrue(error is IllegalStateException) assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET")) } @Test fun `V10 over-budget sender preflights with zero frames sent`() = runBlocking { val (source, _) = memoryPair() var frames = 0 val countingSource = object : PairingTransport { override suspend fun send(msg: ByteArray) { frames++; source.send(msg) } override suspend fun receive(): ByteArray = source.receive() override suspend fun close() {} } val envelope = "c".repeat(690_000) val entries = (0 until 16).associate { "big-$it" to envelope } val error = runCatching { sendVaultSnapshot(countingSource, entries, emptyMap()) }.exceptionOrNull() assertTrue(error is IllegalStateException) assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET")) assertEquals(0, frames) } @Test fun `V11 duress bytes count against the aggregate budget`() { val acc = createAdoptSnapshotBudget() acc.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100 // A 200-byte duress slot does not fit the remaining 100-byte budget. val error = runCatching { applyAdoptSnapshotFrame(acc, emptyMap(), emptyMap(), "d".repeat(200), 200) }.exceptionOrNull() assertTrue(error is IllegalStateException) assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET")) } }