codex.multistripe.test.ts

8.1 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  CHUNK_SIZE,23  MAX_DATA_SHARDS_PER_STRIPE,24  computeParityShards,25  type CodexPayload,26} from "../../shared/codex-types";27import {28  encodeIntoStripes,29  type EncodeResult,30} from "../../popup/codex/erasure";31import {32  reconstructPayloadFromStripes,33  type StripeChunks,34} from "../../popup/codex/generate";3536// ---------------------------------------------------------------------------37// Helpers38// ---------------------------------------------------------------------------3940function makeCodexPayload(paddingKb: number): { payload: CodexPayload; json: string } {41  const payload: CodexPayload = {42    vaultBlob: "A".repeat(paddingKb * 1024),43    verifyCode: "TEST-CODE",44    issuedAt: 0,45    fingerprint: "test-fingerprint",46    entries: [],47    identities: [],48    settings: {},49  } as unknown as CodexPayload;50  return { payload, json: JSON.stringify(payload) };51}5253function encodeResultToStripeChunks(r: EncodeResult): StripeChunks {54  const chunks = new Map<number, Uint8Array>();55  for (let i = 0; i < r.dataChunks.length; i++) {56    chunks.set(i, r.dataChunks[i]!);57  }58  for (let i = 0; i < r.parityChunks.length; i++) {59    chunks.set(r.dataShards + i, r.parityChunks[i]!);60  }61  return { chunks, dataShards: r.dataShards, totalChunks: r.totalChunks };62}6364function encodeResultToStripeChunksWithoutParity(r: EncodeResult): StripeChunks {65  const chunks = new Map<number, Uint8Array>();66  for (let i = 0; i < r.dataChunks.length; i++) {67    chunks.set(i, r.dataChunks[i]!);68  }69  // Parity shards omitted: RS decoder must reconstruct them (erasure mode)70  return { chunks, dataShards: r.dataShards, totalChunks: r.totalChunks };71}7273function buildStripeChunksWithErasures(74  r: EncodeResult,75  eraseParityCount: number,76): StripeChunks {77  const chunks = new Map<number, Uint8Array>();78  for (let i = 0; i < r.dataChunks.length; i++) {79    chunks.set(i, r.dataChunks[i]!);80  }81  // Keep only `parityShards - eraseParityCount` parity shards82  const keep = r.parityChunks.length - eraseParityCount;83  for (let i = 0; i < keep; i++) {84    chunks.set(r.dataShards + i, r.parityChunks[i]!);85  }86  return { chunks, dataShards: r.dataShards, totalChunks: r.totalChunks };87}8889// ---------------------------------------------------------------------------90// Tests91// ---------------------------------------------------------------------------9293describe("T-017: Codex multi-stripe encode/reconstruct", () => {94  // T-017a: a payload larger than one stripe forces multiple stripes.95  // MAX_DATA_SHARDS_PER_STRIPE = 200, CHUNK_SIZE = 350 bytes → one stripe96  // can hold 70,000 bytes. We need > 70,000 bytes compressed to force 2 stripes.97  // Using 200 kB of incompressible-ish data (base64 of random-ish bytes) ensures98  // the compressed output still exceeds the single-stripe capacity.99  it("T-017a: payload > one-stripe capacity → encodeIntoStripes returns 2+ stripes", async () => {100    // 250 kB of text that deflate can't reduce below 70 kB (mostly random chars)101    const hugeText = Array.from({ length: 250 * 1024 }, (_, i) => String.fromCharCode(33 + (i % 90))).join("");102    const compressed = deflate(hugeText);103104    const result = await encodeIntoStripes(compressed);105106    expect(result.stripes.length).toBeGreaterThanOrEqual(2);107    expect(result.totalDataChunks).toBeGreaterThan(MAX_DATA_SHARDS_PER_STRIPE);108    for (const stripe of result.stripes) {109      // GF(2⁸) cap: every stripe must fit within 256 total shards110      expect(stripe.dataShards + stripe.parityShards).toBeLessThanOrEqual(255);111      // No single stripe exceeds the per-stripe cap112      expect(stripe.dataShards).toBeLessThanOrEqual(MAX_DATA_SHARDS_PER_STRIPE);113    }114  });115116  // T-017b: encode a 2-stripe payload, reconstruct it, verify JSON matches.117  // This is the primary multi-stripe correctness check. The concatenation of118  // stripe data must reproduce the original deflated bytes byte-for-byte.119  //120  // Bug this catches: an off-by-CHUNK_SIZE error in the `byteCursor` arithmetic121  // inside `encodeIntoStripes` would corrupt the stripe boundaries, causing122  // `inflateBounded` in `reconstructPayloadFromStripes` to fail or produce123  // wrong JSON.124  it("T-017b: 2-stripe round-trip → reconstructed payload matches original", async () => {125    // 150 kB of ASCII text. deflate typically achieves ~50% for structured text,126    // so compressed ≈ 75 kB > 70 kB single-stripe cap → 2 stripes.127    const { json } = makeCodexPayload(110);128    const compressed = deflate(json);129130    const encoded = await encodeIntoStripes(compressed);131    expect(encoded.stripes.length).toBeGreaterThanOrEqual(2);132133    const stripeChunks: StripeChunks[] = encoded.stripes.map(encodeResultToStripeChunks);134135    const restored = await reconstructPayloadFromStripes(stripeChunks);136    expect(JSON.stringify(restored)).toBe(json);137  });138139  // T-017c: reconstruct with some parity shards missing from each stripe.140  // The Reed-Solomon codec fills in erased shards from the surviving data + parity141  // shards. Dropping up to `parityShards` chunks (here: 1 parity shard per stripe)142  // must still produce a correct reconstruction.143  //144  // Bug this catches: an erasure position bug in `decodeWithErasure` that145  // mis-identifies which shard is erased would corrupt the data even with146  // enough redundancy present.147  it("T-017c: 2-stripe payload with 1 parity shard erased per stripe → reconstruction succeeds", async () => {148    const { json } = makeCodexPayload(110);149    const compressed = deflate(json);150    const encoded = await encodeIntoStripes(compressed);151    expect(encoded.stripes.length).toBeGreaterThanOrEqual(2);152153    // Drop 1 parity shard per stripe — within the RS recovery budget154    const stripeChunks: StripeChunks[] = encoded.stripes.map((r) => {155      const parityBudget = computeParityShards(r.dataShards);156      // Drop exactly 1 parity shard — must be within budget157      if (parityBudget < 1) return encodeResultToStripeChunks(r); // shouldn't happen for real stripes158      return buildStripeChunksWithErasures(r, 1);159    });160161    const restored = await reconstructPayloadFromStripes(stripeChunks);162    expect(JSON.stringify(restored)).toBe(json);163  });164165  // T-017d: empty stripes array → CodexError("INSUFFICIENT_CHUNKS").166  // reconstructPayloadFromStripes must fail fast with a typed error on no input.167  it("T-017d: empty stripes array → throws INSUFFICIENT_CHUNKS", async () => {168    await expect(reconstructPayloadFromStripes([])).rejects.toThrow(/INSUFFICIENT_CHUNKS/i);169  });170171  // T-017e: single-stripe payload still works through reconstructPayloadFromStripes.172  // The multi-stripe path handles single-stripe as a degenerate case.173  it("T-017e: single-stripe small payload round-trips through reconstructPayloadFromStripes", async () => {174    const { json } = makeCodexPayload(1); // 1 kB — fits in a few shards175    const compressed = deflate(json);176    const encoded = await encodeIntoStripes(compressed);177    expect(encoded.stripes.length).toBe(1);178179    const stripeChunks: StripeChunks[] = encoded.stripes.map(encodeResultToStripeChunks);180    const restored = await reconstructPayloadFromStripes(stripeChunks);181    expect(JSON.stringify(restored)).toBe(json);182  });183});