codex.multistripe.test.ts

9.3 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  CodexError,25  computeParityShards,26  type CodexPayload,27} from "../../shared/codex-types";28import {29  encodeIntoStripes,30  type EncodeResult,31} from "./erasure";32import {33  reconstructPayloadFromStripes,34  type StripeChunks,35} from "./generate";3637// ---------------------------------------------------------------------------38// Helpers39// ---------------------------------------------------------------------------4041// Build a CodexPayload whose JSON, when deflated, reliably exceeds the42// single-stripe cap (MAX_DATA_SHARDS_PER_STRIPE × CHUNK_SIZE = 70,000 bytes).43// Using random bytes for vaultBlob (encoded as hex) ensures DEFLATE can't44// collapse the data — truly random hex compresses to ~50%, so 100 kB random45// bytes → 200 kB hex → ~100 kB deflated, comfortably above the 70 kB cap.46// crypto.getRandomValues is capped at 65,536 bytes per call — chunk it.47function getRandomBytes(count: number): Uint8Array {48  const out = new Uint8Array(count);49  const CHUNK = 65536;50  for (let off = 0; off < count; off += CHUNK) {51    crypto.getRandomValues(out.subarray(off, Math.min(off + CHUNK, count)));52  }53  return out;54}5556function makeCodexPayloadWithRandomBlob(): { payload: CodexPayload; json: string } {57  const randomBytes = getRandomBytes(100 * 1024);58  const hexBlob = Array.from(randomBytes).map(b => b.toString(16).padStart(2, "0")).join("");59  const payload: CodexPayload = {60    vaultBlob: hexBlob,61    verifyCode: "TEST-CODE",62    issuedAt: 0,63    fingerprint: "test-fingerprint",64    entries: [],65    identities: [],66    settings: {},67  } as unknown as CodexPayload;68  return { payload, json: JSON.stringify(payload) };69}7071function encodeResultToStripeChunks(r: EncodeResult): StripeChunks {72  const chunks = new Map<number, Uint8Array>();73  for (let i = 0; i < r.dataChunks.length; i++) {74    chunks.set(i, r.dataChunks[i]!);75  }76  for (let i = 0; i < r.parityChunks.length; i++) {77    chunks.set(r.dataShards + i, r.parityChunks[i]!);78  }79  return { chunks, dataShards: r.dataShards, totalChunks: r.totalChunks };80}8182function encodeResultToStripeChunksWithoutParity(r: EncodeResult): StripeChunks {83  const chunks = new Map<number, Uint8Array>();84  for (let i = 0; i < r.dataChunks.length; i++) {85    chunks.set(i, r.dataChunks[i]!);86  }87  // Parity shards omitted: RS decoder must reconstruct them (erasure mode)88  return { chunks, dataShards: r.dataShards, totalChunks: r.totalChunks };89}9091function buildStripeChunksWithErasures(92  r: EncodeResult,93  eraseParityCount: number,94): StripeChunks {95  const chunks = new Map<number, Uint8Array>();96  for (let i = 0; i < r.dataChunks.length; i++) {97    chunks.set(i, r.dataChunks[i]!);98  }99  // Keep only `parityShards - eraseParityCount` parity shards100  const keep = r.parityChunks.length - eraseParityCount;101  for (let i = 0; i < keep; i++) {102    chunks.set(r.dataShards + i, r.parityChunks[i]!);103  }104  return { chunks, dataShards: r.dataShards, totalChunks: r.totalChunks };105}106107// ---------------------------------------------------------------------------108// Tests109// ---------------------------------------------------------------------------110111describe("T-017: Codex multi-stripe encode/reconstruct", () => {112  // T-017a: a payload larger than one stripe forces multiple stripes.113  // MAX_DATA_SHARDS_PER_STRIPE = 200, CHUNK_SIZE = 350 bytes → one stripe114  // can hold 70,000 bytes. We need > 70,000 bytes compressed to force 2 stripes.115  // Using 200 kB of incompressible-ish data (base64 of random-ish bytes) ensures116  // the compressed output still exceeds the single-stripe capacity.117  it("T-017a: payload > one-stripe capacity → encodeIntoStripes returns 2+ stripes", async () => {118    // Pass 75 kB of random bytes directly — MAX_DATA_SHARDS_PER_STRIPE × CHUNK_SIZE119    // = 200 × 350 = 70,000 bytes per stripe, so 75 kB forces a second stripe.120    // No deflate needed here: encodeIntoStripes operates on raw byte spans.121    const compressed = getRandomBytes(75 * 1024);122123    const result = await encodeIntoStripes(compressed);124125    expect(result.stripes.length).toBeGreaterThanOrEqual(2);126    expect(result.totalDataChunks).toBeGreaterThan(MAX_DATA_SHARDS_PER_STRIPE);127    for (const stripe of result.stripes) {128      // GF(2⁸) cap: every stripe must fit within 256 total shards129      expect(stripe.dataShards + stripe.parityShards).toBeLessThanOrEqual(255);130      // No single stripe exceeds the per-stripe cap131      expect(stripe.dataShards).toBeLessThanOrEqual(MAX_DATA_SHARDS_PER_STRIPE);132    }133  });134135  // T-017b: encode a 2-stripe payload, reconstruct it, verify JSON matches.136  // This is the primary multi-stripe correctness check. The concatenation of137  // stripe data must reproduce the original deflated bytes byte-for-byte.138  //139  // Bug this catches: an off-by-CHUNK_SIZE error in the `byteCursor` arithmetic140  // inside `encodeIntoStripes` would corrupt the stripe boundaries, causing141  // `inflateBounded` in `reconstructPayloadFromStripes` to fail or produce142  // wrong JSON.143  it("T-017b: 2-stripe round-trip → reconstructed payload matches original", async () => {144    // Random hex in vaultBlob deflates to ~100 kB (> 70 kB cap) → 2 stripes.145    const { json } = makeCodexPayloadWithRandomBlob();146    const compressed = deflate(json);147148    const encoded = await encodeIntoStripes(compressed);149    expect(encoded.stripes.length).toBeGreaterThanOrEqual(2);150151    const stripeChunks: StripeChunks[] = encoded.stripes.map(encodeResultToStripeChunks);152153    const restored = await reconstructPayloadFromStripes(stripeChunks);154    expect(JSON.stringify(restored)).toBe(json);155  });156157  // T-017c: reconstruct with some parity shards missing from each stripe.158  // The Reed-Solomon codec fills in erased shards from the surviving data + parity159  // shards. Dropping up to `parityShards` chunks (here: 1 parity shard per stripe)160  // must still produce a correct reconstruction.161  //162  // Bug this catches: an erasure position bug in `decodeWithErasure` that163  // mis-identifies which shard is erased would corrupt the data even with164  // enough redundancy present.165  it("T-017c: 2-stripe payload with 1 parity shard erased per stripe → reconstruction succeeds", async () => {166    const { json } = makeCodexPayloadWithRandomBlob();167    const compressed = deflate(json);168    const encoded = await encodeIntoStripes(compressed);169    expect(encoded.stripes.length).toBeGreaterThanOrEqual(2);170171    // Drop 1 parity shard per stripe — within the RS recovery budget172    const stripeChunks: StripeChunks[] = encoded.stripes.map((r) => {173      const parityBudget = computeParityShards(r.dataShards);174      // Drop exactly 1 parity shard — must be within budget175      if (parityBudget < 1) return encodeResultToStripeChunks(r); // shouldn't happen for real stripes176      return buildStripeChunksWithErasures(r, 1);177    });178179    const restored = await reconstructPayloadFromStripes(stripeChunks);180    expect(JSON.stringify(restored)).toBe(json);181  });182183  // T-017d: empty stripes array → CodexError("INSUFFICIENT_CHUNKS").184  // reconstructPayloadFromStripes must fail fast with a typed error on no input.185  // The error code is on the .code property, not baked into the message string.186  it("T-017d: empty stripes array → throws INSUFFICIENT_CHUNKS", async () => {187    const err = await reconstructPayloadFromStripes([]).catch((e: unknown) => e);188    expect(err).toBeInstanceOf(CodexError);189    expect((err as CodexError).code).toBe("INSUFFICIENT_CHUNKS");190  });191192  // T-017e: single-stripe payload still works through reconstructPayloadFromStripes.193  // The multi-stripe path handles single-stripe as a degenerate case.194  it("T-017e: single-stripe small payload round-trips through reconstructPayloadFromStripes", async () => {195    const payload: CodexPayload = {196      vaultBlob: "small",197      verifyCode: "V",198      issuedAt: 0,199      fingerprint: "fp",200      entries: [],201      identities: [],202      settings: {},203    } as unknown as CodexPayload;204    const json = JSON.stringify(payload);205    const compressed = deflate(json);206    const encoded = await encodeIntoStripes(compressed);207    expect(encoded.stripes.length).toBe(1);208209    const stripeChunks: StripeChunks[] = encoded.stripes.map(encodeResultToStripeChunks);210    const restored = await reconstructPayloadFromStripes(stripeChunks);211    expect(JSON.stringify(restored)).toBe(json);212  });213});