codex.multistripe.test.ts

8.9 KB · raw

1// T-017: Codex multi-stripe round-trip2//3// Why this test exists: the Codex erasure layer splits large vaults across4// multiple RS stripes (each capped at MAX_DATA_SHARDS_PER_STRIPE = 200 shards5// × 350 bytes = 70,000 bytes per stripe). Single-stripe round-trips are covered6// by codex.suite.test.ts and codex.adversarial.test.ts. The multi-stripe path7// — encodeIntoStripes() → reconstructPayloadFromStripes() — has no test that8// verifies the concatenation boundary is correct. A one-off-by-CHUNK_SIZE bug9// in the stripe-cursor arithmetic would corrupt the payload silently when the10// compressed vault crosses the 70 kB boundary.11//12// Tests:13//   T-017a: payload > MAX_SHARDS_PER_STRIPE * CHUNK_SIZE → encodeIntoStripes14//           returns 2+ stripes.15//   T-017b: reconstruct a 2-stripe encoding → JSON payload matches original.16//   T-017c: reconstruct with parity shards removed from each stripe → RS fills17//           in (up to parityShards erased chunks per stripe).1819import { deflate } from "pako";20import { describe, expect, it } from "vitest";21import {22  MAX_DATA_SHARDS_PER_STRIPE,23  CodexError,24  computeParityShards,25  type CodexPayload,26} from "../../shared/codex-types";27import {28  encodeIntoStripes,29  type EncodeResult,30} from "./erasure";31import {32  reconstructPayloadFromStripes,33  type StripeChunks,34} from "./generate";3536// ---------------------------------------------------------------------------37// Helpers38// ---------------------------------------------------------------------------3940// Build a CodexPayload whose JSON, when deflated, reliably exceeds the41// single-stripe cap (MAX_DATA_SHARDS_PER_STRIPE × CHUNK_SIZE = 70,000 bytes).42// Using random bytes for vaultBlob (encoded as hex) ensures DEFLATE can't43// collapse the data — truly random hex compresses to ~50%, so 100 kB random44// bytes → 200 kB hex → ~100 kB deflated, comfortably above the 70 kB cap.45// crypto.getRandomValues is capped at 65,536 bytes per call — chunk it.46function getRandomBytes(count: number): Uint8Array {47  const out = new Uint8Array(count);48  const CHUNK = 65536;49  for (let off = 0; off < count; off += CHUNK) {50    crypto.getRandomValues(out.subarray(off, Math.min(off + CHUNK, count)));51  }52  return out;53}5455function makeCodexPayloadWithRandomBlob(): { payload: CodexPayload; json: string } {56  const randomBytes = getRandomBytes(100 * 1024);57  const hexBlob = Array.from(randomBytes).map(b => b.toString(16).padStart(2, "0")).join("");58  const payload: CodexPayload = {59    vaultBlob: hexBlob,60    verifyCode: "TEST-CODE",61    issuedAt: 0,62    fingerprint: "test-fingerprint",63    entries: [],64    identities: [],65    settings: {},66  } as unknown as CodexPayload;67  return { payload, json: JSON.stringify(payload) };68}6970function encodeResultToStripeChunks(r: EncodeResult): StripeChunks {71  const chunks = new Map<number, Uint8Array>();72  for (let i = 0; i < r.dataChunks.length; i++) {73    chunks.set(i, r.dataChunks[i]!);74  }75  for (let i = 0; i < r.parityChunks.length; i++) {76    chunks.set(r.dataShards + i, r.parityChunks[i]!);77  }78  return { chunks, dataShards: r.dataShards, totalChunks: r.totalChunks };79}8081function buildStripeChunksWithErasures(82  r: EncodeResult,83  eraseParityCount: number,84): StripeChunks {85  const chunks = new Map<number, Uint8Array>();86  for (let i = 0; i < r.dataChunks.length; i++) {87    chunks.set(i, r.dataChunks[i]!);88  }89  // Keep only `parityShards - eraseParityCount` parity shards90  const keep = r.parityChunks.length - eraseParityCount;91  for (let i = 0; i < keep; i++) {92    chunks.set(r.dataShards + i, r.parityChunks[i]!);93  }94  return { chunks, dataShards: r.dataShards, totalChunks: r.totalChunks };95}9697// ---------------------------------------------------------------------------98// Tests99// ---------------------------------------------------------------------------100101describe("T-017: Codex multi-stripe encode/reconstruct", () => {102  // T-017a: a payload larger than one stripe forces multiple stripes.103  // MAX_DATA_SHARDS_PER_STRIPE = 200, CHUNK_SIZE = 350 bytes → one stripe104  // can hold 70,000 bytes. We need > 70,000 bytes compressed to force 2 stripes.105  // Using 200 kB of incompressible-ish data (base64 of random-ish bytes) ensures106  // the compressed output still exceeds the single-stripe capacity.107  it("T-017a: payload > one-stripe capacity → encodeIntoStripes returns 2+ stripes", async () => {108    // Pass 75 kB of random bytes directly — MAX_DATA_SHARDS_PER_STRIPE × CHUNK_SIZE109    // = 200 × 350 = 70,000 bytes per stripe, so 75 kB forces a second stripe.110    // No deflate needed here: encodeIntoStripes operates on raw byte spans.111    const compressed = getRandomBytes(75 * 1024);112113    const result = await encodeIntoStripes(compressed);114115    expect(result.stripes.length).toBeGreaterThanOrEqual(2);116    expect(result.totalDataChunks).toBeGreaterThan(MAX_DATA_SHARDS_PER_STRIPE);117    for (const stripe of result.stripes) {118      // GF(2⁸) cap: every stripe must fit within 256 total shards119      expect(stripe.dataShards + stripe.parityShards).toBeLessThanOrEqual(255);120      // No single stripe exceeds the per-stripe cap121      expect(stripe.dataShards).toBeLessThanOrEqual(MAX_DATA_SHARDS_PER_STRIPE);122    }123  });124125  // T-017b: encode a 2-stripe payload, reconstruct it, verify JSON matches.126  // This is the primary multi-stripe correctness check. The concatenation of127  // stripe data must reproduce the original deflated bytes byte-for-byte.128  //129  // Bug this catches: an off-by-CHUNK_SIZE error in the `byteCursor` arithmetic130  // inside `encodeIntoStripes` would corrupt the stripe boundaries, causing131  // `inflateBounded` in `reconstructPayloadFromStripes` to fail or produce132  // wrong JSON.133  it("T-017b: 2-stripe round-trip → reconstructed payload matches original", async () => {134    // Random hex in vaultBlob deflates to ~100 kB (> 70 kB cap) → 2 stripes.135    const { json } = makeCodexPayloadWithRandomBlob();136    const compressed = deflate(json);137138    const encoded = await encodeIntoStripes(compressed);139    expect(encoded.stripes.length).toBeGreaterThanOrEqual(2);140141    const stripeChunks: StripeChunks[] = encoded.stripes.map(encodeResultToStripeChunks);142143    const restored = await reconstructPayloadFromStripes(stripeChunks);144    expect(JSON.stringify(restored)).toBe(json);145  });146147  // T-017c: reconstruct with some parity shards missing from each stripe.148  // The Reed-Solomon codec fills in erased shards from the surviving data + parity149  // shards. Dropping up to `parityShards` chunks (here: 1 parity shard per stripe)150  // must still produce a correct reconstruction.151  //152  // Bug this catches: an erasure position bug in `decodeWithErasure` that153  // mis-identifies which shard is erased would corrupt the data even with154  // enough redundancy present.155  it("T-017c: 2-stripe payload with 1 parity shard erased per stripe → reconstruction succeeds", async () => {156    const { json } = makeCodexPayloadWithRandomBlob();157    const compressed = deflate(json);158    const encoded = await encodeIntoStripes(compressed);159    expect(encoded.stripes.length).toBeGreaterThanOrEqual(2);160161    // Drop 1 parity shard per stripe — within the RS recovery budget162    const stripeChunks: StripeChunks[] = encoded.stripes.map((r) => {163      const parityBudget = computeParityShards(r.dataShards);164      // Drop exactly 1 parity shard — must be within budget165      if (parityBudget < 1) return encodeResultToStripeChunks(r); // shouldn't happen for real stripes166      return buildStripeChunksWithErasures(r, 1);167    });168169    const restored = await reconstructPayloadFromStripes(stripeChunks);170    expect(JSON.stringify(restored)).toBe(json);171  });172173  // T-017d: empty stripes array → CodexError("INSUFFICIENT_CHUNKS").174  // reconstructPayloadFromStripes must fail fast with a typed error on no input.175  // The error code is on the .code property, not baked into the message string.176  it("T-017d: empty stripes array → throws INSUFFICIENT_CHUNKS", async () => {177    const err = await reconstructPayloadFromStripes([]).catch((e: unknown) => e);178    expect(err).toBeInstanceOf(CodexError);179    expect((err as CodexError).code).toBe("INSUFFICIENT_CHUNKS");180  });181182  // T-017e: single-stripe payload still works through reconstructPayloadFromStripes.183  // The multi-stripe path handles single-stripe as a degenerate case.184  it("T-017e: single-stripe small payload round-trips through reconstructPayloadFromStripes", async () => {185    const payload: CodexPayload = {186      vaultBlob: "small",187      verifyCode: "V",188      issuedAt: 0,189      fingerprint: "fp",190      entries: [],191      identities: [],192      settings: {},193    } as unknown as CodexPayload;194    const json = JSON.stringify(payload);195    const compressed = deflate(json);196    const encoded = await encodeIntoStripes(compressed);197    expect(encoded.stripes.length).toBe(1);198199    const stripeChunks: StripeChunks[] = encoded.stripes.map(encodeResultToStripeChunks);200    const restored = await reconstructPayloadFromStripes(stripeChunks);201    expect(JSON.stringify(restored)).toBe(json);202  });203});