CodexCodecTest.kt

10.5 KB · raw

1package com.peachpasswords.codex23import org.junit.Assert.assertArrayEquals4import org.junit.Assert.assertEquals5import org.junit.Assert.assertTrue6import org.junit.Test7import kotlin.random.Random89class CodexCodecTest {1011    @Test fun rs_recovers_dropped_shards() {12        val data = 613        val parity = computeParityShards(data) // 214        val size = 4015        val rng = Random(7)16        val original = Array(data) { ByteArray(size) { rng.nextInt().toByte() } }17        val shards = Array(data + parity) { i -> if (i < data) original[i].copyOf() else ByteArray(size) }18        ReedSolomon.encode(shards, data, parity)1920        // Drop `parity` shards (the max tolerable) — here drop 2 data shards.21        val present = BooleanArray(data + parity) { true }22        shards[1] = ByteArray(0); present[1] = false23        shards[4] = ByteArray(0); present[4] = false24        ReedSolomon.reconstruct(shards, data, parity, present)2526        for (i in 0 until data) assertArrayEquals("shard $i", original[i], shards[i])27    }2829    @Test fun gf_mul_div_are_inverse() {30        for (a in 1..255) for (b in 1..255) {31            assertEquals(a, Galois.div(Galois.mul(a, b), b))32        }33    }3435    @Test fun full_codec_round_trip() {36        val payload = CodexPayload(37            version = 2, verifyCode = "PX-AB12-CD34",38            vaultBlob = "x".repeat(5000), timestamp = 111, entryCount = 7,39            vaultFingerprint = "DEADBEEF".repeat(4), generatedAt = 222,40        )41        val bytes = CodexCodec.json.encodeToString(CodexPayload.serializer(), payload).toByteArray()42        val compressed = CodexCodec.deflate(bytes)43        assertArrayEquals(bytes, CodexCodec.inflate(compressed))4445        val (envelopes, layout) = CodexCodec.encodeToEnvelopes(compressed, payload.verifyCode)46        assertTrue(envelopes.all { CodexCodec.verifyChunkIntegrity(it) })47        assertEquals(layout.dataChunks + layout.parityChunks, envelopes.size)4849        // Lose the maximum tolerable codes in stripe 0, then rebuild.50        val parity = layout.parityChunks / layout.stripeCount51        val survivors = envelopes.toMutableList()52        repeat(parity) { survivors.removeAt(0) } // drop first `parity` data shards53        val recovered = CodexCodec.reconstructCompressed(survivors)54        val parsed = CodexCodec.json.decodeFromString(CodexPayload.serializer(), String(CodexCodec.inflate(recovered)))55        assertEquals(payload, parsed)56    }5758    @Test fun corrupted_chunk_fails_integrity() {59        val (envelopes, _) = CodexCodec.encodeToEnvelopes(CodexCodec.deflate("hello world".toByteArray()), "PX-0000-0000")60        val tampered = envelopes.first().copy(d = CodexCodec.base64Url("tampered".toByteArray()))61        assertTrue(!CodexCodec.verifyChunkIntegrity(tampered))62    }6364    @Test fun inflate_exactly_at_the_cap_round_trips() {65        val data = ByteArray(CodexCodec.MAX_INFLATED_BYTES.toInt()) // zeros compress to ~17 KB66        assertArrayEquals(data, CodexCodec.inflate(CodexCodec.deflate(data)))67    }6869    /**70     * PAI-2026-002: the per-chunk hash covers `d` only, so a hostile code71     * passes verifyChunkIntegrity with a tiny payload and any `t`/`S`/`i` it72     * likes. Every case below used to reach Array(t) before being rejected.73     */74    private fun hostileChunk(75        total: Int = 4,76        index: Int = 0,77        stripe: Int = 0,78        stripeCount: Int = 1,79        data: ByteArray = ByteArray(CHUNK_SIZE),80        verifyCode: String = "PX-AB12-CD34",81    ): ChunkEnvelope = ChunkEnvelope(82        v = 3, c = verifyCode, s = stripe, capS = stripeCount, i = index, t = total,83        p = index >= total - 2,84        d = CodexCodec.base64Url(data),85        h = CodexCodec.sha256Hex(data).take(32),86    )8788    @Test fun hostile_shard_total_is_rejected_before_allocation() {89        val hostile = hostileChunk(total = Int.MAX_VALUE)90        assertTrue("geometry is rejected", !CodexCodec.isValidEnvelope(hostile))91        val error = try {92            CodexCodec.reconstructCompressed(listOf(hostile))93            null94        } catch (e: CodexException) {95            e96        }97        assertEquals(CodexErrorCode.INSUFFICIENT_CHUNKS, error?.code)98    }99100    @Test fun out_of_range_shard_and_stripe_indices_are_rejected() {101        assertTrue(!CodexCodec.isValidEnvelope(hostileChunk(total = 4, index = 4)))102        assertTrue(!CodexCodec.isValidEnvelope(hostileChunk(total = 4, index = -1)))103        assertTrue(!CodexCodec.isValidEnvelope(hostileChunk(stripe = 2, stripeCount = 2)))104        assertTrue(!CodexCodec.isValidEnvelope(hostileChunk(stripeCount = 0)))105        assertTrue(!CodexCodec.isValidEnvelope(hostileChunk(stripeCount = 100_000)))106        assertTrue(!CodexCodec.isValidEnvelope(hostileChunk(total = 256)))107    }108109    @Test fun oversized_chunk_data_is_rejected() {110        // One byte past the ceiling, and far past a full-range Aztec code's capacity.111        assertTrue(!CodexCodec.isValidEnvelope(hostileChunk(data = ByteArray(4097))))112        assertTrue(!CodexCodec.isValidEnvelope(hostileChunk(data = ByteArray(64 * 1024))))113        assertTrue(CodexCodec.isValidEnvelope(hostileChunk()))114    }115116    /**117     * Cross-platform: the browser's decoder derives the shard size from the118     * first scanned chunk so that Codexes printed before the current119     * [CHUNK_SIZE] (the previous value was 800 bytes) still restore, and it120     * enforces no chunk length of its own. Android's Reed-Solomon decoder121     * derives shard size the same way. Pinning the chunk length to the current122     * generator's would turn every such printed Codex into an unreadable one.123     */124    @Test fun a_codex_printed_with_an_older_chunk_size_still_restores() {125        val legacyChunkSize = 800126        val payload = ByteArray(legacyChunkSize * 2) { (it % 251).toByte() }127        // t = 4 inverts to 2 data + 2 parity shards; every shard is present, so128        // reconstruction is just the concatenation of the two data shards.129        val shards = listOf(130            payload.copyOfRange(0, legacyChunkSize),131            payload.copyOfRange(legacyChunkSize, legacyChunkSize * 2),132            ByteArray(legacyChunkSize),133            ByteArray(legacyChunkSize),134        )135        val envelopes = shards.mapIndexed { i, bytes ->136            ChunkEnvelope(137                v = 3, c = "PX-AB12-CD34", s = 0, capS = 1, i = i, t = 4,138                p = i >= 2, d = CodexCodec.base64Url(bytes),139                h = CodexCodec.sha256Hex(bytes).take(32),140            )141        }142        assertTrue("legacy envelopes are accepted", envelopes.all { CodexCodec.isValidEnvelope(it) })143        assertArrayEquals(payload, CodexCodec.reconstructCompressed(envelopes))144    }145146    /**147     * Bounding each envelope is not enough on its own: a hostile Codex can148     * simply carry many valid stripes. The whole reconstruction is budgeted149     * against the inflate cap, which it can never legitimately exceed.150     */151    @Test fun a_reconstruction_past_the_inflate_cap_is_refused() {152        val chunk = ByteArray(4096) // the largest chunk a single envelope may carry153        val stripeCount = 20 // 20 × 230 × 4096 is past the 16 MiB inflate cap154        val envelopes = (0 until stripeCount).flatMap { s ->155            (0 until 230).map { i ->156                ChunkEnvelope(157                    v = 3, c = "PX-AB12-CD34", s = s, capS = stripeCount,158                    i = i, t = 230, p = false,159                    d = CodexCodec.base64Url(chunk), h = CodexCodec.sha256Hex(chunk).take(32),160                )161            }162        }163        val error = try {164            CodexCodec.reconstructCompressed(envelopes); null165        } catch (e: CodexException) {166            e167        }168        assertEquals(CodexErrorCode.PAYLOAD_TOO_LARGE, error?.code)169    }170171    @Test fun malformed_verify_code_is_rejected() {172        assertTrue(!CodexCodec.isValidEnvelope(hostileChunk(verifyCode = "PX-AB12-CD3")))173        assertTrue(CodexCodec.isValidEnvelope(hostileChunk()))174    }175176    @Test fun mixed_verify_codes_and_shard_totals_are_rejected() {177        val (envelopes, _) = CodexCodec.encodeToEnvelopes(178            CodexCodec.deflate("hello codex".toByteArray()),179            "PX-AB12-CD34",180        )181        val other = CodexCodec.encodeToEnvelopes(182            CodexCodec.deflate("hello codex".toByteArray()),183            "PX-ZZ99-YY88",184        ).first.first()185        var error = try {186            CodexCodec.reconstructCompressed(envelopes + other); null187        } catch (e: CodexException) {188            e189        }190        assertEquals(CodexErrorCode.INVALID_ENVELOPE, error?.code)191192        val mixedTotal = envelopes.first().copy(t = envelopes.first().t + 1, i = 0)193        error = try {194            CodexCodec.reconstructCompressed(listOf(envelopes.first(), mixedTotal)); null195        } catch (e: CodexException) {196            e197        }198        assertEquals(CodexErrorCode.INVALID_ENVELOPE, error?.code)199    }200201    @Test fun hostile_envelopes_are_skipped_and_real_codes_still_restore() {202        val (envelopes, _) = CodexCodec.encodeToEnvelopes(203            CodexCodec.deflate("survives hostile neighbours".toByteArray()),204            "PX-AB12-CD34",205        )206        val recovered = CodexCodec.reconstructCompressed(207            listOf(hostileChunk(total = Int.MAX_VALUE)) + envelopes +208                listOf(hostileChunk(verifyCode = "nope")),209        )210        assertEquals("survives hostile neighbours", String(CodexCodec.inflate(recovered)))211    }212213    @Test fun inflate_refuses_decompression_bombs() {214        val bomb = CodexCodec.deflate(ByteArray(CodexCodec.MAX_INFLATED_BYTES.toInt() + 1))215        assertTrue("bomb compresses tiny", bomb.size < 64 * 1024)216        val error = try {217            CodexCodec.inflate(bomb)218            null219        } catch (e: CodexException) {220            e221        }222        assertEquals(CodexErrorCode.PAYLOAD_TOO_LARGE, error?.code)223    }224225    /**226     * PB-26-004: a malformed compressed stream that returns 0 bytes without227     * finishing or needing input must not loop — it must fail as CORRUPTED.228     * A truncated stream (valid header, cut off mid-block) triggers exactly229     * this: the inflater has input but cannot make progress.230     */231    @Test fun inflate_rejects_malformed_no_progress_stream() {232        val valid = CodexCodec.deflate("hello world".toByteArray())233        // Truncate past the zlib header so the inflater has data but can't finish.234        val truncated = valid.copyOf(valid.size / 2)235        val error = try {236            CodexCodec.inflate(truncated)237            null238        } catch (e: CodexException) {239            e240        }241        assertEquals(CodexErrorCode.CORRUPTED, error?.code)242    }243}