3fd072568f50

mirror adoption snapshot aggregate budget and sync fallback (PB-26-206, v2.14.99)

time
2026-09-03 10:56 UTC · 1 month ago
machine
koh2
agent
muse-spark-1.3-contributor-free
session
PB-26-206 adoption snapshot budget
parent
fe1e4e781366
3 files VERSION+1 −1app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+133 −15app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+203 −19
VERSION+1 −1
@@ -1 +1 @@1-2.14.981+2.14.99

view file

app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+133 −15
@@ -169,8 +169,94 @@169169 170170 // Headroom added to the per-chunk entries budget when validating an encoded171171 // frame on the wire: JSON envelope plus the final chunk's tombstone map.172-private const val SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024172+internal const val SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024173173 174+/**175+ * PB-26-206 aggregate budget shared by sender and receiver — exact mirror of176+ * the browser's `vault-adopt.ts` ceilings. The per-frame guard above bounds177+ * one frame; this bounds the whole transfer independently of the 200-frame178+ * count, so many individually valid frames cannot OOM the receiver.179+ */180+const val MAX_ADOPT_SNAPSHOT_BYTES = 10 * 1024 * 1024 // 10 MiB181+const val MAX_ADOPT_SNAPSHOT_ENTRIES = 25_000182+const val MAX_ADOPT_SNAPSHOT_TOMBSTONES = 25_000183+/** Same value as [SyncLimits.MAX_ID_LENGTH]; named here so vectors read identically on both clients. */184+const val MAX_ADOPT_SNAPSHOT_ID_CHARS = SyncLimits.MAX_ID_LENGTH185+/**186+ * Maximum encrypted entry envelope: plaintext ceiling plus the existing187+ * base64 overhead allowance. Int arithmetic gives 699,114 — the browser's188+ * float comparison accepts and rejects at exactly the same lengths.189+ */190+const val MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES = SyncLimits.MAX_ENTRY_ENVELOPE_BYTES191+192+/**193+ * Running aggregate for one snapshot transfer. Counts ride alongside the maps194+ * (never re-derived per frame) so the bound stays O(n).195+ */196+class AdoptSnapshotBudget {197+    val entries = LinkedHashMap<String, String>()198+    val tombstones = LinkedHashMap<String, Long>()199+    var duressSlot: String? = null200+    /** Sum of encoded wire-frame bytes consumed so far. */201+    var bytes: Int = 0202+    var entryCount: Int = 0203+    var tombstoneCount: Int = 0204+}205+206+fun createAdoptSnapshotBudget(): AdoptSnapshotBudget = AdoptSnapshotBudget()207+208+/**209+ * PB-26-206 shared preflight/merge helper used by BOTH the sender (preflight210+ * before the first frame goes out) and the receiver (per frame, after the211+ * byte-budget check, before decoded maps are merged).212+ *213+ * One bounded pass per attacker-controlled map: each key/value is validated214+ * and projected counts are enforced inside the same loop that merges, so the215+ * maps are never iterated count→size→merge repeatedly. Throws before merging216+ * the offending frame, leaving no partial adopted state.217+ */218+fun applyAdoptSnapshotFrame(219+    acc: AdoptSnapshotBudget,220+    entries: Map<String, String>,221+    tombstones: Map<String, Long>,222+    duressSlot: String?,223+    frameBytes: Int,224+) {225+    if (frameBytes > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {226+        throw IllegalStateException("ADOPT_SNAPSHOT_OVER_BUDGET")227+    }228+    for ((id, envelope) in entries) {229+        if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw IllegalStateException("ADOPT_SNAPSHOT_ID_TOO_LONG")230+        if (envelope.length > MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) {231+            throw IllegalStateException("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE")232+        }233+        if (acc.entryCount >= MAX_ADOPT_SNAPSHOT_ENTRIES) {234+            throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES")235+        }236+        acc.entryCount++237+        acc.entries[id] = envelope238+    }239+    for ((id, ts) in tombstones) {240+        if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw IllegalStateException("ADOPT_SNAPSHOT_ID_TOO_LONG")241+        // Long is always finite, so non-finite hostile JSON dies at the242+        // strict codec decode before reaching this merge (see V7).243+        if (acc.tombstoneCount >= MAX_ADOPT_SNAPSHOT_TOMBSTONES) {244+            throw IllegalStateException("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES")245+        }246+        acc.tombstoneCount++247+        acc.tombstones[id] = ts248+    }249+    if (!duressSlot.isNullOrEmpty()) {250+        // The duress slot rides inside wire frames already bounded by the251+        // frame ceiling, and its raw bytes count against the aggregate above.252+        if (duressSlot.length > SNAPSHOT_FRAME_WIRE_CEILING) {253+            throw IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")254+        }255+        acc.duressSlot = duressSlot256+    }257+    acc.bytes += frameBytes258+}259+174260 /** What the RECEIVER (joining device) needs to adopt a vault from a source. */175261 interface AdoptionReceiverDeps {176262     val deviceName: String@@ -520,6 +606,33 @@520606     tombstones: Map<String, Long>,521607     duressSlot: String? = null,522608 ) {609+    val frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot)610+    // PB-26-206 preflight with the SAME helper the receiver enforces: nothing611+    // is sent until every frame fits the aggregate budget, so an over-budget612+    // snapshot is never partially sent or silently truncated. The caller falls613+    // through to the regular sync pipeline (deny-only: no wire-field change,614+    // invitation prefix unchanged).615+    val acc = createAdoptSnapshotBudget()616+    val encoded = frames.map {617+        AdoptJson.encodeToString(AdoptionFrame.serializer(), it).encodeToByteArray()618+    }619+    for (i in frames.indices) {620+        val frame = frames[i]621+        applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duressSlot, encoded[i].size)622+    }623+    for (raw in encoded) channel.send(raw)624+}625+626+/**627+ * Pure frame construction for [sendVaultSnapshot]: chunking, the duress-frame628+ * reservation, and the oversized-duress hard stop. No I/O happens here, so629+ * the preflight above can reject before the first byte goes out.630+ */631+private fun buildAdoptSnapshotFrames(632+    entries: Map<String, String>,633+    tombstones: Map<String, Long>,634+    duressSlot: String?,635+): List<AdoptionFrame.Snapshot> {523636     val includeDuress = !duressSlot.isNullOrBlank()524637     // Reserve one frame for duress when configured so duress is never525638     // silently dropped at the cap; ordinary sync does NOT carry duress on@@ -563,9 +676,10 @@563676         throw SnapshotDuressFrameTooLargeException()564677     }565678 679+    val frames = mutableListOf<AdoptionFrame.Snapshot>()566680     for (index in 0 until entriesChunkCount) {567681         val isFinalEntries = index == entriesChunkCount - 1568-        sendFrame(channel, AdoptionFrame.Snapshot(682+        frames.add(AdoptionFrame.Snapshot(569683             chunkIndex = index,570684             chunkCount = totalChunks,571685             entries = chunks[index],@@ -577,29 +691,35 @@577691         ))578692     }579693 580-    if (duressFrame != null) sendFrame(channel, duressFrame)694+    if (duressFrame != null) frames.add(duressFrame)695+    return frames581696 }582697 583698 internal class SnapshotDuressFrameTooLargeException :584699     IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")585700 586701 /**587- * Receiver side: receive and reassemble every snapshot chunk. Validates the588- * encoded frame size before parsing, then enforces sequential 0-based589- * chunk_index ordering, a stable chunk_count within [MAX_SNAPSHOT_CHUNKS], and590- * structural validity of each frame. Throws on any validation failure so the591- * caller can fall back to the sync pipeline.702+ * Receiver side: receive and reassemble every snapshot chunk. Adds raw frame703+ * bytes to the aggregate counter before decoding (rejecting before JSON704+ * deserialization when the next frame exceeds the remaining budget), then705+ * enforces the per-frame wire ceiling, sequential 0-based chunk_index706+ * ordering, a stable chunk_count within [MAX_SNAPSHOT_CHUNKS], and structural707+ * validity of each frame. Merging goes through the shared aggregate helper:708+ * every key/value and projected count is validated in one bounded pass before709+ * anything lands in the accumulator, so a rejection leaves no partial adopted710+ * state and the caller can fall back to the sync pipeline.592711  */593712 suspend fun receiveVaultSnapshot(594713     channel: PairingTransport,595714 ): ReceivedSnapshot {596-    val entries = LinkedHashMap<String, String>()597-    val tombstones = LinkedHashMap<String, Long>()598-    var duressSlot: String? = null715+    val acc = createAdoptSnapshotBudget()599716     var chunkCount = -1600717     var received = 0601718     while (chunkCount == -1 || received < chunkCount) {602719         val raw = channel.receive()720+        if (raw.size > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {721+            throw IllegalStateException("ADOPT_SNAPSHOT_OVER_BUDGET")722+        }603723         if (raw.size > SNAPSHOT_FRAME_WIRE_CEILING) {604724             throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE")605725         }@@ -619,12 +739,10 @@619739         if (frame.chunkIndex != received) {620740             throw IllegalStateException("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER")621741         }622-        entries.putAll(frame.entries)623-        tombstones.putAll(frame.tombstones)624-        if (frame.duressSlot != null) duressSlot = frame.duressSlot742+        applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duressSlot, raw.size)625743         received++626744     }627-    return ReceivedSnapshot(entries, tombstones, duressSlot)745+    return ReceivedSnapshot(acc.entries, acc.tombstones, acc.duressSlot)628746 }629747 630748 private fun isDeviceType(value: String): Boolean =

view file

app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+203 −19
@@ -308,14 +308,13 @@308308 309309     // Mirrors browser T-A36: cap boundary inclusive. With 199 entries and a310310     // duress slot, the sender fits everything within MAX_SNAPSHOT_CHUNKS = 200311-    // (199 entries + 1 duress = 200 frames). Duress MUST arrive intact — sync312-    // does not carry duress. Sequential pattern matches the rest of the file313-    // (channels buffer via UNLIMITED capacity).311+    // (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.314313     @Test315314     fun `duress is reserved a frame so 199 entries plus duress fits in MAX_SNAPSHOT_CHUNKS`() = runBlocking {316315         val (source, receiver) = memoryPair()317316         val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""318-        val big = "x".repeat(450 * 1024)317+        val big = "x".repeat(40 * 1024)319318         val entries = (0 until 199).associate { "id-$it" to big }320319         sendVaultSnapshot(source, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)321320         val snapshot = receiveVaultSnapshot(receiver)@@ -324,24 +323,30 @@324323         assertEquals(duressJson, snapshot.duressSlot)325324     }326325 327-    // Mirrors browser T-A37: cap boundary exceeded. With 200 entries + duress,328-    // the duress frame is reserved (entry cap = MAX_SNAPSHOT_CHUNKS - 1 = 199),329-    // so duress still arrives intact. Excess entries (one beyond 199) are330-    // deferred to sync. Critically: duress MUST NOT be silently dropped, else331-    // the adopted device lacks its duress vault permanently.326+    // 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.332331     @Test333-    fun `200 entries plus duress does not silently drop duress at the chunk cap`() = runBlocking {334-        val (source, receiver) = memoryPair()332+    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+        }335340         val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""336-        val big = "x".repeat(450 * 1024)341+        // 200 entries × 60 KiB ≈ 12 MiB > 10 MiB aggregate budget.342+        val big = "x".repeat(60 * 1024)337343         val entries = (0 until 200).associate { "id-$it" to big }338-        sendVaultSnapshot(source, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)339-        val snapshot = receiveVaultSnapshot(receiver)340-        // Duress MUST arrive intact.341-        assertEquals(duressJson, snapshot.duressSlot)342-        // The inline cap = MAX_SNAPSHOT_CHUNKS - 1 = 199 entries.343-        assertEquals(199, snapshot.entries.size)344-        assertEquals(mapOf("dead-0" to 1L), snapshot.tombstones)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)345350     }346351 347352     // Cap-pre-check guardrail: a duress payload that itself exceeds the wire@@ -367,5 +372,184 @@367372         }.exceptionOrNull()368373         assertTrue(error is SnapshotDuressFrameTooLargeException)369374         assertEquals(0, frames)375+    }376+377+    // 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.380+381+    @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)387+388+        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+    }398+399+    @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+    }412+413+    @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+    }426+427+    @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])433+434+        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+    }446+447+    @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"])453+454+        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+    }461+462+    @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)467+468+        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+    }477+478+    @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+    }493+494+    @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+    }502+503+    @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+    }524+525+    @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+    }543+544+    @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"))370554     }371555 }

view file