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 314 @Test315 fun `duress is reserved a frame so 199 entries plus duress fits in MAX_SNAPSHOT_CHUNKS`() = runBlocking {316 val (source, receiver) = memoryPair()317 val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""318 val big = "x".repeat(450 * 1024)319 val entries = (0 until 199).associate { "id-$it" to big }320 sendVaultSnapshot(source, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)321 val snapshot = receiveVaultSnapshot(receiver)322 assertEquals(entries, snapshot.entries)323 assertEquals(mapOf("dead-0" to 1L), snapshot.tombstones)324 assertEquals(duressJson, snapshot.duressSlot)325 }326327 328 329 330 331 332 @Test333 fun `200 entries plus duress does not silently drop duress at the chunk cap`() = runBlocking {334 val (source, receiver) = memoryPair()335 val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""336 val big = "x".repeat(450 * 1024)337 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 341 assertEquals(duressJson, snapshot.duressSlot)342 343 assertEquals(199, snapshot.entries.size)344 assertEquals(mapOf("dead-0" to 1L), snapshot.tombstones)345 }346347 348 349 350 @Test351 fun `duress larger than wire ceiling throws loudly before sending`() = runBlocking {352 val (source, _) = memoryPair()353 var frames = 0354 val countingSource = object : PairingTransport {355 override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }356 override suspend fun receive(): ByteArray = source.receive()357 override suspend fun close() {}358 }359 val ceiling = 768 * 1024360 val emptyWireSize = AdoptJson.encodeToString(361 AdoptionFrame.serializer(),362 AdoptionFrame.Snapshot(1, 2, emptyMap(), emptyMap(), ""),363 ).encodeToByteArray().size364 val duressJson = "x".repeat(ceiling - emptyWireSize + 1)365 val error = runCatching {366 sendVaultSnapshot(countingSource, mapOf("id-0" to "small"), emptyMap(), duressSlot = duressJson)367 }.exceptionOrNull()368 assertTrue(error is SnapshotDuressFrameTooLargeException)369 assertEquals(0, frames)370 }371}