// T-017: Codex multi-stripe round-trip // // Why this test exists: the Codex erasure layer splits large vaults across // multiple RS stripes (each capped at MAX_DATA_SHARDS_PER_STRIPE = 200 shards // × 350 bytes = 70,000 bytes per stripe). Single-stripe round-trips are covered // by codex.suite.test.ts and codex.adversarial.test.ts. The multi-stripe path // — encodeIntoStripes() → reconstructPayloadFromStripes() — has no test that // verifies the concatenation boundary is correct. A one-off-by-CHUNK_SIZE bug // in the stripe-cursor arithmetic would corrupt the payload silently when the // compressed vault crosses the 70 kB boundary. // // Tests: // T-017a: payload > MAX_SHARDS_PER_STRIPE * CHUNK_SIZE → encodeIntoStripes // returns 2+ stripes. // T-017b: reconstruct a 2-stripe encoding → JSON payload matches original. // T-017c: reconstruct with parity shards removed from each stripe → RS fills // in (up to parityShards erased chunks per stripe). import { deflate } from "pako"; import { describe, expect, it } from "vitest"; import { CHUNK_SIZE, MAX_DATA_SHARDS_PER_STRIPE, computeParityShards, type CodexPayload, } from "../../shared/codex-types"; import { encodeIntoStripes, type EncodeResult, } from "../../popup/codex/erasure"; import { reconstructPayloadFromStripes, type StripeChunks, } from "../../popup/codex/generate"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeCodexPayload(paddingKb: number): { payload: CodexPayload; json: string } { const payload: CodexPayload = { vaultBlob: "A".repeat(paddingKb * 1024), verifyCode: "TEST-CODE", issuedAt: 0, fingerprint: "test-fingerprint", entries: [], identities: [], settings: {}, } as unknown as CodexPayload; return { payload, json: JSON.stringify(payload) }; } function encodeResultToStripeChunks(r: EncodeResult): StripeChunks { const chunks = new Map(); for (let i = 0; i < r.dataChunks.length; i++) { chunks.set(i, r.dataChunks[i]!); } for (let i = 0; i < r.parityChunks.length; i++) { chunks.set(r.dataShards + i, r.parityChunks[i]!); } return { chunks, dataShards: r.dataShards, totalChunks: r.totalChunks }; } function encodeResultToStripeChunksWithoutParity(r: EncodeResult): StripeChunks { const chunks = new Map(); for (let i = 0; i < r.dataChunks.length; i++) { chunks.set(i, r.dataChunks[i]!); } // Parity shards omitted: RS decoder must reconstruct them (erasure mode) return { chunks, dataShards: r.dataShards, totalChunks: r.totalChunks }; } function buildStripeChunksWithErasures( r: EncodeResult, eraseParityCount: number, ): StripeChunks { const chunks = new Map(); for (let i = 0; i < r.dataChunks.length; i++) { chunks.set(i, r.dataChunks[i]!); } // Keep only `parityShards - eraseParityCount` parity shards const keep = r.parityChunks.length - eraseParityCount; for (let i = 0; i < keep; i++) { chunks.set(r.dataShards + i, r.parityChunks[i]!); } return { chunks, dataShards: r.dataShards, totalChunks: r.totalChunks }; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("T-017: Codex multi-stripe encode/reconstruct", () => { // T-017a: a payload larger than one stripe forces multiple stripes. // MAX_DATA_SHARDS_PER_STRIPE = 200, CHUNK_SIZE = 350 bytes → one stripe // can hold 70,000 bytes. We need > 70,000 bytes compressed to force 2 stripes. // Using 200 kB of incompressible-ish data (base64 of random-ish bytes) ensures // the compressed output still exceeds the single-stripe capacity. it("T-017a: payload > one-stripe capacity → encodeIntoStripes returns 2+ stripes", async () => { // 250 kB of text that deflate can't reduce below 70 kB (mostly random chars) const hugeText = Array.from({ length: 250 * 1024 }, (_, i) => String.fromCharCode(33 + (i % 90))).join(""); const compressed = deflate(hugeText); const result = await encodeIntoStripes(compressed); expect(result.stripes.length).toBeGreaterThanOrEqual(2); expect(result.totalDataChunks).toBeGreaterThan(MAX_DATA_SHARDS_PER_STRIPE); for (const stripe of result.stripes) { // GF(2⁸) cap: every stripe must fit within 256 total shards expect(stripe.dataShards + stripe.parityShards).toBeLessThanOrEqual(255); // No single stripe exceeds the per-stripe cap expect(stripe.dataShards).toBeLessThanOrEqual(MAX_DATA_SHARDS_PER_STRIPE); } }); // T-017b: encode a 2-stripe payload, reconstruct it, verify JSON matches. // This is the primary multi-stripe correctness check. The concatenation of // stripe data must reproduce the original deflated bytes byte-for-byte. // // Bug this catches: an off-by-CHUNK_SIZE error in the `byteCursor` arithmetic // inside `encodeIntoStripes` would corrupt the stripe boundaries, causing // `inflateBounded` in `reconstructPayloadFromStripes` to fail or produce // wrong JSON. it("T-017b: 2-stripe round-trip → reconstructed payload matches original", async () => { // 150 kB of ASCII text. deflate typically achieves ~50% for structured text, // so compressed ≈ 75 kB > 70 kB single-stripe cap → 2 stripes. const { json } = makeCodexPayload(110); const compressed = deflate(json); const encoded = await encodeIntoStripes(compressed); expect(encoded.stripes.length).toBeGreaterThanOrEqual(2); const stripeChunks: StripeChunks[] = encoded.stripes.map(encodeResultToStripeChunks); const restored = await reconstructPayloadFromStripes(stripeChunks); expect(JSON.stringify(restored)).toBe(json); }); // T-017c: reconstruct with some parity shards missing from each stripe. // The Reed-Solomon codec fills in erased shards from the surviving data + parity // shards. Dropping up to `parityShards` chunks (here: 1 parity shard per stripe) // must still produce a correct reconstruction. // // Bug this catches: an erasure position bug in `decodeWithErasure` that // mis-identifies which shard is erased would corrupt the data even with // enough redundancy present. it("T-017c: 2-stripe payload with 1 parity shard erased per stripe → reconstruction succeeds", async () => { const { json } = makeCodexPayload(110); const compressed = deflate(json); const encoded = await encodeIntoStripes(compressed); expect(encoded.stripes.length).toBeGreaterThanOrEqual(2); // Drop 1 parity shard per stripe — within the RS recovery budget const stripeChunks: StripeChunks[] = encoded.stripes.map((r) => { const parityBudget = computeParityShards(r.dataShards); // Drop exactly 1 parity shard — must be within budget if (parityBudget < 1) return encodeResultToStripeChunks(r); // shouldn't happen for real stripes return buildStripeChunksWithErasures(r, 1); }); const restored = await reconstructPayloadFromStripes(stripeChunks); expect(JSON.stringify(restored)).toBe(json); }); // T-017d: empty stripes array → CodexError("INSUFFICIENT_CHUNKS"). // reconstructPayloadFromStripes must fail fast with a typed error on no input. it("T-017d: empty stripes array → throws INSUFFICIENT_CHUNKS", async () => { await expect(reconstructPayloadFromStripes([])).rejects.toThrow(/INSUFFICIENT_CHUNKS/i); }); // T-017e: single-stripe payload still works through reconstructPayloadFromStripes. // The multi-stripe path handles single-stripe as a degenerate case. it("T-017e: single-stripe small payload round-trips through reconstructPayloadFromStripes", async () => { const { json } = makeCodexPayload(1); // 1 kB — fits in a few shards const compressed = deflate(json); const encoded = await encodeIntoStripes(compressed); expect(encoded.stripes.length).toBe(1); const stripeChunks: StripeChunks[] = encoded.stripes.map(encodeResultToStripeChunks); const restored = await reconstructPayloadFromStripes(stripeChunks); expect(JSON.stringify(restored)).toBe(json); }); });