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
↓ peach-android-3fd07256.tar.gzatlas at this save
files changed3 files
VERSION+1 −1app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+133 −15app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+203 −19app/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 =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 }