VaultAdoptSnapshotTest.kt

8.7 KB · raw

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.Test910/**11 * Inline vault snapshot protocol parity with the browser12 * `vault-adopt-snapshot.test.ts`. Round-trip, chunking, and validation13 * tests for the ADOPT_SNAPSHOT / ADOPT_SNAPSHOT_APPLIED frames.14 */15class 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        // Absent when null (encodeDefaults = false)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        // H-09: a hostile source could send an arbitrarily large frame to exhaust134        // receiver memory before JSON parsing. receiveVaultSnapshot checks the raw135        // byte length against SNAPSHOT_FRAME_WIRE_CEILING (512 KiB + 256 KiB = 768 KiB)136        // and throws before attempting deserialization.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}