scan-limits.test.ts

12.5 KB · raw

1/**2 * scan-limits.test.ts — PB-26-207 resource bounds on the Codex scan path.3 *4 * Every limit here is derived from the Codex format, not from available5 * memory: raw scanner text ≤ 4 KiB before JSON.parse; `d` ≤ base64url6 * expansion of one 350-byte shard before atob; decoded chunk ≤ shard size +7 * explicit headroom; `c`/`h` exact lengths and integer ranges before any8 * integrity work; buffer bytes/chunks ≤ MAX_STRIPE_COUNT × max shards per9 * stripe × chunk size. The camera gate allows one parse/add in flight.10 */1112import { beforeEach, describe, expect, it, vi } from "vitest";1314import {15  CHUNK_SIZE,16  CODEX_MAX_PARITY_SHARDS,17  MAX_DATA_SHARDS_PER_STRIPE,18  MAX_STRIPE_COUNT,19  type ChunkEnvelope,20} from "../../shared/codex-types";21import { buildChunkEnvelope, bytesToBase64Url, sha256Hex } from "./helpers";22import { _resetReedSolomonCacheForTest } from "./rs-loader";23import {24  CHUNK_HASH_LENGTH,25  ChunkBuffer,26  createScanGate,27  MAX_BUFFER_BYTES,28  MAX_BUFFER_CHUNKS,29  MAX_CHUNK_B64_CHARS,30  MAX_DECODED_CHUNK_BYTES,31  MAX_RAW_SCAN_CHARS,32  MAX_SHARDS_PER_STRIPE,33  parseRawScan,34  VERIFY_CODE_LENGTH,35} from "./restore";3637beforeEach(() => {38  _resetReedSolomonCacheForTest();39});4041const VERIFY_CODE = "PX-TEST-ABCD";4243async function makeEnv(opts: {44  bytes?: Uint8Array;45  index?: number;46  total?: number;47  verifyCode?: string;48  stripe?: number;49  stripeCount?: number;50} = {}): Promise<ChunkEnvelope> {51  return buildChunkEnvelope(52    opts.bytes ?? new Uint8Array(CHUNK_SIZE).fill(7),53    opts.index ?? 0,54    opts.total ?? 5,55    false,56    opts.verifyCode ?? VERIFY_CODE,57    opts.stripe ?? 0,58    opts.stripeCount ?? 1,59  );60}6162describe("PB-26-207 limit constants derive from the Codex format", () => {63  it("raw scan ceiling is 4 KiB", () => {64    expect(MAX_RAW_SCAN_CHARS).toBe(4096);65  });6667  it("d ceiling is the base64url expansion of one shard", () => {68    expect(MAX_CHUNK_B64_CHARS).toBe(Math.ceil(CHUNK_SIZE / 3) * 4);69  });7071  it("decoded chunk ceiling is one shard plus explicit headroom", () => {72    expect(MAX_DECODED_CHUNK_BYTES).toBeGreaterThan(CHUNK_SIZE);73    expect(MAX_DECODED_CHUNK_BYTES - CHUNK_SIZE).toBeLessThanOrEqual(128);74  });7576  it("aggregate ceiling is stripes × max shards per stripe × chunk size", () => {77    expect(MAX_SHARDS_PER_STRIPE).toBe(78      MAX_DATA_SHARDS_PER_STRIPE + CODEX_MAX_PARITY_SHARDS,79    );80    expect(MAX_BUFFER_CHUNKS).toBe(MAX_STRIPE_COUNT * MAX_SHARDS_PER_STRIPE);81    expect(MAX_BUFFER_BYTES).toBe(MAX_BUFFER_CHUNKS * CHUNK_SIZE);82  });8384  it("c and h lengths match the documented envelope format", () => {85    expect(VERIFY_CODE_LENGTH).toBe("PX-XXXX-XXXX".length);86    expect(CHUNK_HASH_LENGTH).toBe(32);87  });88});8990describe("PB-26-207 parseRawScan bounds", () => {91  it("rejects oversized raw text without calling JSON.parse", async () => {92    const env = await makeEnv();93    const raw = JSON.stringify(env);94    expect(raw.length).toBeLessThan(MAX_RAW_SCAN_CHARS);95    const padded = raw.padEnd(MAX_RAW_SCAN_CHARS + 1, " ");96    const spy = vi.spyOn(JSON, "parse");97    try {98      expect(parseRawScan(padded)).toEqual({ error: "parse_error" });99      expect(spy).not.toHaveBeenCalled();100    } finally {101      spy.mockRestore();102    }103  });104105  it("accepts a valid envelope padded to exactly the raw ceiling", async () => {106    const env = await makeEnv();107    const padded = JSON.stringify(env).padEnd(MAX_RAW_SCAN_CHARS, " ");108    expect(padded.length).toBe(MAX_RAW_SCAN_CHARS);109    expect(parseRawScan(padded)).toMatchObject({ v: 3, c: VERIFY_CODE });110  });111112  it("rejects non-string raw input without throwing", () => {113    expect(parseRawScan(12345 as unknown as string)).toEqual({114      error: "parse_error",115    });116  });117118  it("honest envelopes sit far below the raw ceiling", async () => {119    const env = await makeEnv();120    expect(JSON.stringify(env).length).toBeLessThan(MAX_RAW_SCAN_CHARS / 2);121  });122123  it("rejects wrong-length c and h before integrity work", async () => {124    const env = await makeEnv();125    expect(126      parseRawScan(JSON.stringify({ ...env, c: "PX-SHORT" })),127    ).toEqual({ error: "parse_error" });128    expect(129      parseRawScan(JSON.stringify({ ...env, c: `${VERIFY_CODE}XX` })),130    ).toEqual({ error: "parse_error" });131    expect(parseRawScan(JSON.stringify({ ...env, h: "ABCDEF" }))).toEqual({132      error: "parse_error",133    });134    expect(135      parseRawScan(JSON.stringify({ ...env, h: `${env.h}FF` })),136    ).toEqual({ error: "parse_error" });137  });138139  it("rejects oversized d before decoding", async () => {140    const env = await makeEnv();141    expect(142      parseRawScan(143        JSON.stringify({ ...env, d: `${env.d}AAAA` }),144      ),145    ).toEqual({ error: "parse_error" });146  });147148  it("rejects out-of-range stripe/chunk indices", async () => {149    const env = await makeEnv();150    const cases: Array<Partial<ChunkEnvelope>> = [151      { S: 0 },152      { S: MAX_STRIPE_COUNT + 1 },153      { s: 1, S: 1 },154      { s: -1 },155      { i: -1 },156      { i: env.t },157      { t: 0 },158      { t: MAX_SHARDS_PER_STRIPE + 1 },159    ];160    for (const patch of cases) {161      expect(162        parseRawScan(JSON.stringify({ ...env, ...patch })),163        JSON.stringify(patch),164      ).toEqual({ error: "parse_error" });165    }166  });167168  it("accepts boundary indices: S=64, s=S-1, i=t-1", async () => {169    const env = await makeEnv({ index: 4, total: 5, stripe: 63, stripeCount: 64 });170    expect(parseRawScan(JSON.stringify(env))).toMatchObject({171      v: 3,172      S: 64,173      s: 63,174      i: 4,175      t: 5,176    });177  });178});179180describe("PB-26-207 ChunkBuffer.add validates before decode/hash", () => {181  it("rejects oversized d with an honest hash as parse_error, not bad_integrity", async () => {182    // 400-byte chunk: d is ~534 chars and the hash genuinely matches, so a183    // post-hash check would accept it. parse_error proves the length gate ran184    // before atob/alloc/hash.185    const bytes = new Uint8Array(400).fill(9);186    const env = await buildChunkEnvelope(bytes, 0, 5, false, VERIFY_CODE);187    expect(env.d.length).toBeGreaterThan(MAX_CHUNK_B64_CHARS);188    const buf = new ChunkBuffer();189    expect(await buf.add(env)).toEqual({190      accepted: false,191      reason: "parse_error",192    });193    expect(buf.size()).toBe(0);194    expect(buf.bufferedBytes()).toBe(0);195  });196197  it("d boundary: 468 chars accepted, 469 rejected", async () => {198    // 351 bytes encode to exactly 468 unpadded chars and stay within the199    // decoded headroom; a padded 469-char twin of a 350-byte chunk is not.200    const bytes351 = new Uint8Array(351).fill(3);201    const atLimit = await buildChunkEnvelope(bytes351, 0, 5, false, VERIFY_CODE);202    expect(atLimit.d.length).toBe(MAX_CHUNK_B64_CHARS);203    expect((await new ChunkBuffer().add(atLimit)).accepted).toBe(true);204205    const base = await makeEnv();206    const over = { ...base, d: `${base.d}==` };207    expect(over.d.length).toBe(MAX_CHUNK_B64_CHARS + 1);208    const buf = new ChunkBuffer();209    expect(await buf.add(over)).toEqual({210      accepted: false,211      reason: "parse_error",212    });213    expect(buf.size()).toBe(0);214  });215216  it("rejects wrong-length c/h and non-boolean p without hashing", async () => {217    const env = await makeEnv();218    for (const patch of [219      { c: "PX-SHORT" },220      { c: `${VERIFY_CODE}XX` },221      { h: "00" },222      { h: `${env.h}FF` },223      { p: "yes" },224    ]) {225      const buf = new ChunkBuffer();226      expect(await buf.add({ ...env, ...patch } as ChunkEnvelope)).toEqual({227        accepted: false,228        reason: "parse_error",229      });230      expect(buf.getContext()).toBeNull();231    }232  });233234  it("rejects an oversized stripe count before decoding", async () => {235    const env = await buildChunkEnvelope(236      new Uint8Array([1, 2, 3]),237      0,238      5,239      false,240      VERIFY_CODE,241      0,242      MAX_STRIPE_COUNT + 1,243    );244    const buf = new ChunkBuffer();245    expect(await buf.add(env)).toEqual({246      accepted: false,247      reason: "parse_error",248    });249    expect(buf.getContext()).toBeNull();250  });251252  it("tracks decoded bytes on accept only", async () => {253    const buf = new ChunkBuffer();254    expect(buf.bufferedBytes()).toBe(0);255    const first = await makeEnv({ index: 0, bytes: new Uint8Array(100).fill(1) });256    expect((await buf.add(first)).accepted).toBe(true);257    expect(buf.bufferedBytes()).toBe(100);258259    // Duplicate, foreign, corrupt, and malformed inputs leave the counter flat.260    expect(await buf.add(first)).toEqual({261      accepted: false,262      reason: "duplicate",263    });264    const foreign = await makeEnv({ index: 1, verifyCode: "PX-OTHR-XXXX" });265    expect(await buf.add(foreign)).toEqual({266      accepted: false,267      reason: "foreign_codex",268    });269    const badHash = await makeEnv({ index: 1 });270    expect(await buf.add({ ...badHash, h: `FF${badHash.h.slice(2)}` })).toEqual(271      {272        accepted: false,273        reason: "bad_integrity",274      },275    );276    const malformed = await makeEnv({ index: 1 });277    expect(await buf.add({ ...malformed, d: "x".repeat(9999) })).toEqual({278      accepted: false,279      reason: "parse_error",280    });281    expect(buf.bufferedBytes()).toBe(100);282    expect(buf.size()).toBe(1);283  });284285  it("enforces the aggregate byte/chunk ceiling: exact fits, +1 fails", async () => {286    // A 1-chunk, 350-byte budget keeps the enforcement path fast: the287    // production ceiling uses the same comparison against larger constants.288    const buf = new ChunkBuffer({ maxBytes: CHUNK_SIZE, maxChunks: 1 });289    const first = await makeEnv({ index: 0 });290    expect((await buf.add(first)).accepted).toBe(true);291    expect(buf.bufferedBytes()).toBe(CHUNK_SIZE);292293    const second = await makeEnv({ index: 1 });294    expect(await buf.add(second)).toEqual({295      accepted: false,296      reason: "parse_error",297    });298    expect(buf.size()).toBe(1);299    expect(buf.bufferedBytes()).toBe(CHUNK_SIZE);300  });301302  it("honest 350-byte shards count toward the real aggregate ceiling", async () => {303    const buf = new ChunkBuffer();304    const env = await makeEnv({ index: 0 });305    expect((await buf.add(env)).accepted).toBe(true);306    expect(buf.bufferedBytes()).toBe(CHUNK_SIZE);307    expect(buf.bufferedBytes()).toBeLessThanOrEqual(MAX_BUFFER_BYTES);308    expect(buf.size()).toBeLessThanOrEqual(MAX_BUFFER_CHUNKS);309  });310});311312describe("PB-26-207 camera scan gate", () => {313  it("runs one scan at a time and drops repeats while busy", async () => {314    let release!: () => void;315    const gate = new Promise<void>((resolve) => {316      release = resolve;317    });318    let calls = 0;319    const scan = createScanGate(async () => {320      calls++;321      await gate;322    });323324    scan.onScan("first");325    scan.onScan("second");326    scan.onScan("third");327    await Promise.resolve();328    await Promise.resolve();329    expect(calls).toBe(1);330331    release();332    await gate;333    await Promise.resolve();334    await Promise.resolve();335    // Dropped callbacks are never replayed; the next frame starts new work.336    scan.onScan("fourth");337    await Promise.resolve();338    await Promise.resolve();339    expect(calls).toBe(2);340  });341342  it("a throwing scan resets the gate without an unhandled rejection", async () => {343    const errors: unknown[] = [];344    const scan = createScanGate(async () => {345      throw new Error("boom");346    });347    scan.onScan("bad");348    await new Promise((resolve) => setTimeout(resolve, 10));349    // Gate is usable again: a second failure would hang or throw here.350    let second = 0;351    const ok = createScanGate(async () => {352      second++;353    });354    ok.onScan("good");355    await new Promise((resolve) => setTimeout(resolve, 10));356    expect(second).toBe(1);357    expect(errors).toEqual([]);358  });359});360361describe("PB-26-207 existing fixtures remain valid", () => {362  it("a generator-built envelope passes every new gate", async () => {363    const bytes = new Uint8Array(CHUNK_SIZE).fill(11);364    const env = await buildChunkEnvelope(bytes, 2, 5, false, VERIFY_CODE);365    expect(env.d.length).toBeLessThanOrEqual(MAX_CHUNK_B64_CHARS);366    const parsed = parseRawScan(JSON.stringify(env));367    expect("error" in parsed).toBe(false);368    const buf = new ChunkBuffer();369    expect((await buf.add(parsed as ChunkEnvelope)).accepted).toBe(true);370    expect(buf.bufferedBytes()).toBe(CHUNK_SIZE);371  });372373  it("base64url helpers still round-trip the shard size", () => {374    const bytes = new Uint8Array(CHUNK_SIZE).fill(5);375    const encoded = bytesToBase64Url(bytes);376    expect(encoded.length).toBeLessThanOrEqual(MAX_CHUNK_B64_CHARS);377  });378379  it("chunk hash helper still emits the exact documented length", async () => {380    const hash = (381      await sha256Hex(new Uint8Array(CHUNK_SIZE).fill(5))382    ).slice(0, 32);383    expect(hash).toHaveLength(CHUNK_HASH_LENGTH);384  });385});