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 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 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 134 135 136 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 215 216 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 223 224 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 237 238 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 261262 sendVaultSnapshot(probe, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)263264 assertEquals(2, observedRawSizes.size)265 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 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 280 281 org.junit.Assert.assertTrue(282 "combined raw ${observedRawSizes[0] + observedRawSizes[1]} should exceed ceiling $ceiling",283 observedRawSizes[0] + observedRawSizes[1] > ceiling284 )285 }286287 288 289 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 295 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 310 311 312 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 327 328 329 330 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 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 353 354 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 378 379 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 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 481 482 483 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 506 507 val (source, receiver) = memoryPair()508 val envelope = "c".repeat(690_000)509 val chunks = 16510 511 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 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}