// 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 { MAX_DATA_SHARDS_PER_STRIPE, CodexError, computeParityShards, type CodexPayload, } from "../../shared/codex-types"; import { encodeIntoStripes, type EncodeResult, } from "./erasure"; import { reconstructPayloadFromStripes, type StripeChunks, } from "./generate"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // Build a CodexPayload whose JSON, when deflated, reliably exceeds the // single-stripe cap (MAX_DATA_SHARDS_PER_STRIPE × CHUNK_SIZE = 70,000 bytes). // Using random bytes for vaultBlob (encoded as hex) ensures DEFLATE can't // collapse the data — truly random hex compresses to ~50%, so 100 kB random // bytes → 200 kB hex → ~100 kB deflated, comfortably above the 70 kB cap. // crypto.getRandomValues is capped at 65,536 bytes per call — chunk it. function getRandomBytes(count: number): Uint8Array { const out = new Uint8Array(count); const CHUNK = 65536; for (let off = 0; off < count; off += CHUNK) { crypto.getRandomValues(out.subarray(off, Math.min(off + CHUNK, count))); } return out; } function makeCodexPayloadWithRandomBlob(): { payload: CodexPayload; json: string } { const randomBytes = getRandomBytes(100 * 1024); const hexBlob = Array.from(randomBytes).map(b => b.toString(16).padStart(2, "0")).join(""); const payload: CodexPayload = { vaultBlob: hexBlob, 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 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 () => { // Pass 75 kB of random bytes directly — MAX_DATA_SHARDS_PER_STRIPE × CHUNK_SIZE // = 200 × 350 = 70,000 bytes per stripe, so 75 kB forces a second stripe. // No deflate needed here: encodeIntoStripes operates on raw byte spans. const compressed = getRandomBytes(75 * 1024); 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 () => { // Random hex in vaultBlob deflates to ~100 kB (> 70 kB cap) → 2 stripes. const { json } = makeCodexPayloadWithRandomBlob(); 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 } = makeCodexPayloadWithRandomBlob(); 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. // The error code is on the .code property, not baked into the message string. it("T-017d: empty stripes array → throws INSUFFICIENT_CHUNKS", async () => { const err = await reconstructPayloadFromStripes([]).catch((e: unknown) => e); expect(err).toBeInstanceOf(CodexError); expect((err as CodexError).code).toBe("INSUFFICIENT_CHUNKS"); }); // 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 payload: CodexPayload = { vaultBlob: "small", verifyCode: "V", issuedAt: 0, fingerprint: "fp", entries: [], identities: [], settings: {}, } as unknown as CodexPayload; const json = JSON.stringify(payload); 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); }); });