123456789101112import { 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 183 184 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 199 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 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 287 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 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 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});