12345678910import { beforeEach, describe, expect, it } from "vitest";11import { deflate } from "pako";1213import {14 CodexError,15 CHUNK_SIZE,16 computeParityShards,17 deriveShardCounts,18 type ChunkEnvelope,19 type CodexPayload,20} from "../../shared/codex-types";21import {22 bytesToBase64Url,23 buildChunkEnvelope,24 sha256Hex,25 verifyChunkIntegrity,26} from "./helpers";27import { encodeWithErasure, decodeWithErasure } from "./erasure";28import { _resetReedSolomonCacheForTest } from "./rs-loader";29import { ChunkBuffer, parseRawScan, reconstructAndVerify } from "./restore";3031beforeEach(() => {32 _resetReedSolomonCacheForTest();33});3435const VERIFY_CODE = "PX-TEST-1234";3637async function makeEnv(opts: {38 index: number;39 total: number;40 isParity?: boolean;41 verifyCode?: string;42 bytes?: Uint8Array;43}): Promise<ChunkEnvelope> {44 return buildChunkEnvelope(45 opts.bytes ?? new Uint8Array([1, 2, 3, 4, 5]),46 opts.index,47 opts.total,48 opts.isParity ?? false,49 opts.verifyCode ?? VERIFY_CODE50 );51}5253async function makePayload(seedLength = 12_000): Promise<CodexPayload> {54 const blobBytes = new Uint8Array(seedLength);55 let s = 0x9e3779b9;56 for (let i = 0; i < seedLength; i++) {57 s = (s ^ (s << 13)) >>> 0;58 s = (s ^ (s >>> 17)) >>> 0;59 s = (s ^ (s << 5)) >>> 0;60 blobBytes[i] = s & 0xff;61 }62 const vaultBlob = bytesToBase64Url(blobBytes);63 const vaultFingerprint = (64 await sha256Hex(new TextEncoder().encode(vaultBlob))65 )66 .slice(0, 32)67 .toUpperCase();68 return {69 version: 2,70 verifyCode: VERIFY_CODE,71 vaultBlob,72 timestamp: 1_700_000_000_000,73 entryCount: 42,74 vaultFingerprint,75 generatedAt: 1_700_000_001_000,76 };77}7879async function buildEnvelopesForPayload(80 payload: CodexPayload81): Promise<ChunkEnvelope[]> {82 const json = JSON.stringify(payload);83 const compressed = deflate(new TextEncoder().encode(json), { level: 9 });84 const { dataChunks, parityChunks, totalChunks } =85 await encodeWithErasure(compressed);86 const all = [...dataChunks, ...parityChunks];87 return Promise.all(88 all.map((bytes, i) =>89 buildChunkEnvelope(90 bytes,91 i,92 totalChunks,93 i >= dataChunks.length,94 payload.verifyCode95 )96 )97 );98}99100101102describe("parseRawScan adversarial", () => {103 const garbageInputs = [104 "",105 " ",106 "\n",107 "not json at all",108 "{not_valid_json",109 "null",110 "true",111 "42",112 '"a string"',113 "[]",114 "[1,2,3]",115 ];116 for (const raw of garbageInputs) {117 it(`returns parse_error for ${JSON.stringify(raw)}`, () => {118 const result = parseRawScan(raw);119 expect(result).toHaveProperty("error");120 });121 }122123 const wrongVersionInputs = [124 JSON.stringify({ v: 1, c: VERIFY_CODE, i: 0, t: 5 }),125 JSON.stringify({ v: 4, c: VERIFY_CODE, i: 0, t: 5 }),126 JSON.stringify({ v: 0, c: VERIFY_CODE, i: 0, t: 5 }),127 JSON.stringify({ v: "2", c: VERIFY_CODE, i: 0, t: 5 }),128 JSON.stringify({ v: -1 }),129 JSON.stringify({ v: 99 }),130 ];131 for (const raw of wrongVersionInputs) {132 it(`reports wrong_version for ${JSON.stringify(raw).slice(0, 40)}`, () => {133 const result = parseRawScan(raw);134 expect(result).toMatchObject({ error: "wrong_version" });135 });136 }137138 const missingFieldInputs = [139 JSON.stringify({ v: 3 }),140 JSON.stringify({ v: 3, c: VERIFY_CODE }),141 JSON.stringify({ v: 3, c: VERIFY_CODE, i: 0 }),142 JSON.stringify({ v: 3, c: VERIFY_CODE, i: 0, t: 5 }),143 JSON.stringify({ v: 3, c: VERIFY_CODE, i: 0, t: 5, p: false }),144 JSON.stringify({ v: 3, c: VERIFY_CODE, i: 0, t: 5, p: false, d: "x" }),145 ];146 for (const raw of missingFieldInputs) {147 it(`parse_error for missing fields: ${raw.slice(0, 60)}`, () => {148 const result = parseRawScan(raw);149 expect(result).toMatchObject({ error: "parse_error" });150 });151 }152153 it("returns envelope for a valid envelope JSON", async () => {154 const env = await makeEnv({ index: 0, total: 5 });155 const result = parseRawScan(JSON.stringify(env));156 expect(result).toMatchObject({ v: 3, c: VERIFY_CODE, i: 0, t: 5 });157 });158159 it("extra unknown fields do not break parseRawScan", async () => {160 const env = await makeEnv({ index: 0, total: 5 });161 const raw = JSON.stringify({ ...env, extra: "field", more: 42 });162 const result = parseRawScan(raw);163 expect(result).toMatchObject({ v: 3, c: VERIFY_CODE });164 });165});166167168169describe("ChunkBuffer adversarial", () => {170 it("foreign verifyCode is rejected as foreign_codex", async () => {171 const buf = new ChunkBuffer();172 await buf.add(await makeEnv({ index: 0, total: 5 }));173 const res = await buf.add(174 await makeEnv({ index: 1, total: 5, verifyCode: "PX-OTHR-XXXX" })175 );176 expect(res).toEqual({ accepted: false, reason: "foreign_codex" });177 });178179 it("malformed hash length is rejected as parse_error before hashing", async () => {180 181 182 const buf = new ChunkBuffer();183 const env = await makeEnv({ index: 0, total: 5 });184 const tampered = { ...env, h: "00000000" };185 const res = await buf.add(tampered);186 expect(res).toEqual({ accepted: false, reason: "parse_error" });187 });188189 it("data tampered without rehashing is rejected", async () => {190 const buf = new ChunkBuffer();191 const env = await makeEnv({ index: 0, total: 5 });192 const tampered: ChunkEnvelope = {193 ...env,194 d: bytesToBase64Url(new Uint8Array([99, 99, 99])),195 };196 const res = await buf.add(tampered);197 expect(res).toEqual({ accepted: false, reason: "bad_integrity" });198 });199200 it("wrong envelope version is rejected as parse_error", async () => {201 const buf = new ChunkBuffer();202 const env = await makeEnv({ index: 0, total: 5 });203 const wrong = { ...env, v: 1 } as unknown as ChunkEnvelope;204 const res = await buf.add(wrong);205 expect(res).toEqual({ accepted: false, reason: "parse_error" });206 });207208 it("duplicate chunk index is dedup'd", async () => {209 const buf = new ChunkBuffer();210 const env = await makeEnv({ index: 2, total: 5 });211 expect((await buf.add(env)).accepted).toBe(true);212 const second = await buf.add(env);213 expect(second).toEqual({ accepted: false, reason: "duplicate" });214 expect(buf.size()).toBe(1);215 });216217 for (const idx of [0, 1, 2, 3, 4]) {218 it(`accepts chunk at index ${idx}`, async () => {219 const buf = new ChunkBuffer();220 const env = await makeEnv({ index: idx, total: 5 });221 const res = await buf.add(env);222 expect(res.accepted).toBe(true);223 });224 }225226 for (const total of [3, 5, 7, 10, 20]) {227 it(`context dataShards matches deriveShardCounts at total=${total}`, async () => {228 const buf = new ChunkBuffer();229 await buf.add(await makeEnv({ index: 0, total }));230 const ctx = buf.getContext();231 expect(ctx?.dataShards).toBe(deriveShardCounts(total).dataShards);232 });233 }234235 it("reconstructAndVerify on empty buffer throws CodexError", async () => {236 const buf = new ChunkBuffer();237 await expect(reconstructAndVerify(buf)).rejects.toThrow(CodexError);238 });239240 241 242 it("derive_shard_counts_rejects_absurd_total", () => {243 expect(() => deriveShardCounts(1_000_000_000)).toThrow(CodexError);244 });245246 it("derive_shard_counts_accepts_real_max", () => {247 248 const total = 200 + computeParityShards(200);249 const counts = deriveShardCounts(total);250 expect(counts.dataShards).toBe(200);251 });252253 254 255 it("chunk_buffer_rejects_oversized_stripe_count", async () => {256 const buf = new ChunkBuffer();257 const env = await buildChunkEnvelope(new Uint8Array([1, 2, 3, 4, 5]), 0, 5, false, VERIFY_CODE, 0, 2 ** 31);258 const res = await buf.add(env);259 expect(res).toEqual({ accepted: false, reason: "parse_error" });260 expect(buf.getContext()).toBeNull();261 });262263 it("chunk_buffer_accepts_max_stripe_count", async () => {264 const buf = new ChunkBuffer();265 const env = await buildChunkEnvelope(new Uint8Array([1, 2, 3, 4, 5]), 0, 5, false, VERIFY_CODE, 0, 64);266 const res = await buf.add(env);267 expect(res.accepted).toBe(true);268 });269270 271 272 it("parse_raw_scan_rejects_non_integer_index", async () => {273 const env = await makeEnv({ index: 0, total: 5 });274 expect(parseRawScan(JSON.stringify({ ...env, i: 1.5 }))).toEqual({ error: "parse_error" });275 expect(parseRawScan(JSON.stringify({ ...env, t: 5.5 }))).toEqual({ error: "parse_error" });276 expect(parseRawScan(JSON.stringify({ ...env, s: 0.5 }))).toEqual({ error: "parse_error" });277 expect(parseRawScan(JSON.stringify({ ...env, S: 1.5 }))).toEqual({ error: "parse_error" });278 });279280 281 282 it("chunk_buffer_add_does_not_throw_on_invalid_base64", async () => {283 const buf = new ChunkBuffer();284 const env = await makeEnv({ index: 0, total: 5 });285 const res = await buf.add({ ...env, d: "!!!@@" });286 expect(res).toEqual({ accepted: false, reason: "parse_error" });287 expect(buf.getContext()).toBeNull();288 });289290 291 292 it("chunk_buffer_out_of_range_index_reports_parse_error", async () => {293 const buf = new ChunkBuffer();294 const env = await makeEnv({ index: 5, total: 5 }); 295 const res = await buf.add(env);296 expect(res).toEqual({ accepted: false, reason: "parse_error" });297 });298});299300301302describe("verifyChunkIntegrity adversarial", () => {303 it("correct envelope verifies true", async () => {304 const env = await makeEnv({ index: 0, total: 5 });305 expect(await verifyChunkIntegrity(env)).toBe(true);306 });307308 const corruptHashes = [309 "00000000",310 "FFFFFFFF",311 "ZZZZZZZZ",312 "abcdef12",313 "",314 ];315 for (const h of corruptHashes) {316 it(`tampered hash ${JSON.stringify(h)} verifies false`, async () => {317 const env = await makeEnv({ index: 0, total: 5 });318 const tampered = { ...env, h };319 expect(await verifyChunkIntegrity(tampered)).toBe(false);320 });321 }322323 for (let i = 0; i < 5; i++) {324 it(`random data tamper #${i} verifies false`, async () => {325 const env = await makeEnv({ index: 0, total: 5 });326 const bytes = new Uint8Array([327 Math.floor(Math.random() * 256),328 Math.floor(Math.random() * 256),329 Math.floor(Math.random() * 256),330 ]);331 const tampered = { ...env, d: bytesToBase64Url(bytes) };332 333 const recomputed = (await sha256Hex(bytes)).slice(0, 16);334 if (recomputed === env.h) return; 335 expect(await verifyChunkIntegrity(tampered)).toBe(false);336 });337 }338});339340341342describe("encodeWithErasure / decodeWithErasure adversarial", () => {343 it("encoding empty input still produces at least 1 data shard", async () => {344 const { dataChunks, parityChunks, totalChunks, dataShards, parityShards } =345 await encodeWithErasure(new Uint8Array());346 expect(dataShards).toBeGreaterThanOrEqual(1);347 expect(parityShards).toBe(computeParityShards(dataShards));348 expect(totalChunks).toBe(dataShards + parityShards);349 expect(dataChunks.length).toBe(dataShards);350 expect(parityChunks.length).toBe(parityShards);351 });352353 for (const size of [1, 100, 1023, 1024, 1025, 5000, 10000]) {354 it(`encode/decode round-trips data of size ${size}`, async () => {355 const data = crypto.getRandomValues(new Uint8Array(size));356 const { dataChunks, parityChunks, dataShards, totalChunks } =357 await encodeWithErasure(data);358 const map = new Map<number, Uint8Array>();359 [...dataChunks, ...parityChunks].forEach((c, i) => map.set(i, c));360 const decoded = await decodeWithErasure(map, dataShards, totalChunks);361 expect(decoded.length).toBe(dataShards * CHUNK_SIZE);362 363 for (let i = 0; i < size; i++) {364 expect(decoded[i]).toBe(data[i]);365 }366 });367 }368369 it("decoding with empty chunk map throws CodexError", async () => {370 await expect(371 decodeWithErasure(new Map(), 5, 7)372 ).rejects.toThrow(CodexError);373 });374375 it("decoding with fewer chunks than dataShards throws CodexError", async () => {376 const data = crypto.getRandomValues(new Uint8Array(5000));377 const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(378 data379 );380 381 const map = new Map<number, Uint8Array>();382 map.set(0, dataChunks[0]!);383 await expect(384 decodeWithErasure(map, dataShards, totalChunks)385 ).rejects.toThrow(CodexError);386 });387388 it("chunk index out of range throws CodexError", async () => {389 const data = crypto.getRandomValues(new Uint8Array(2048));390 const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(391 data392 );393 const map = new Map<number, Uint8Array>();394 map.set(999, dataChunks[0]!);395 await expect(396 decodeWithErasure(map, dataShards, totalChunks)397 ).rejects.toThrow(CodexError);398 });399400 it("chunk with wrong size throws CodexError", async () => {401 const data = crypto.getRandomValues(new Uint8Array(2048));402 const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(403 data404 );405 const map = new Map<number, Uint8Array>();406 407 map.set(0, new Uint8Array(100));408 for (let i = 1; i < dataShards; i++) map.set(i, dataChunks[i]!);409 await expect(410 decodeWithErasure(map, dataShards, totalChunks)411 ).rejects.toThrow(CodexError);412 });413414 it("dropping 1 data chunk (within parity budget) reconstructs", async () => {415 const data = crypto.getRandomValues(new Uint8Array(8000));416 const { dataChunks, parityChunks, dataShards, totalChunks } =417 await encodeWithErasure(data);418 const map = new Map<number, Uint8Array>();419 420 for (let i = 1; i < dataShards; i++) map.set(i, dataChunks[i]!);421 parityChunks.forEach((c, j) => map.set(dataShards + j, c));422 const decoded = await decodeWithErasure(map, dataShards, totalChunks);423 expect(decoded.length).toBe(dataShards * CHUNK_SIZE);424 for (let i = 0; i < data.length; i++) {425 expect(decoded[i]).toBe(data[i]);426 }427 });428429 it("dropping all data chunks (only parity present) fails when k < dataShards", async () => {430 const data = crypto.getRandomValues(new Uint8Array(8000));431 const { parityChunks, dataShards, totalChunks } = await encodeWithErasure(432 data433 );434 435 if (parityChunks.length >= dataShards) return;436 const map = new Map<number, Uint8Array>();437 parityChunks.forEach((c, j) => map.set(dataShards + j, c));438 await expect(439 decodeWithErasure(map, dataShards, totalChunks)440 ).rejects.toThrow(CodexError);441 });442443 it("dropping too many data chunks (more than parity) fails cleanly", async () => {444 const data = crypto.getRandomValues(new Uint8Array(20000));445 const { dataChunks, parityChunks, dataShards, parityShards, totalChunks } =446 await encodeWithErasure(data);447 if (dataShards <= parityShards + 1) return; 448 const map = new Map<number, Uint8Array>();449 450 for (let i = parityShards + 1; i < dataShards; i++) {451 map.set(i, dataChunks[i]!);452 }453 parityChunks.forEach((c, j) => map.set(dataShards + j, c));454 await expect(455 decodeWithErasure(map, dataShards, totalChunks)456 ).rejects.toThrow(CodexError);457 });458});459460461462describe("reconstructAndVerify adversarial", () => {463 it("round-trips a payload via ChunkBuffer + reconstructAndVerify", async () => {464 const payload = await makePayload(2000);465 const envs = await buildEnvelopesForPayload(payload);466 const buf = new ChunkBuffer();467 for (const e of envs) await buf.add(e);468 const reconstructed = await reconstructAndVerify(buf);469 expect(reconstructed.vaultFingerprint).toBe(payload.vaultFingerprint);470 expect(reconstructed.entryCount).toBe(payload.entryCount);471 });472473 474 475 it("reconstruct_rejects_empty_fingerprint", async () => {476 const base = await makePayload(2000);477 const payload = { ...base, vaultFingerprint: "" };478 const envs = await buildEnvelopesForPayload(payload);479 const buf = new ChunkBuffer();480 for (const e of envs) await buf.add(e);481 await expect(reconstructAndVerify(buf)).rejects.toThrow(CodexError);482 });483484 it("reconstruct_accepts_current_fingerprint_length", async () => {485 const base = await makePayload(2500);486 const fp32 = (await sha256Hex(new TextEncoder().encode(base.vaultBlob))).slice(0, 32).toUpperCase();487 const payload = { ...base, vaultFingerprint: fp32 };488 const envs = await buildEnvelopesForPayload(payload);489 const buf = new ChunkBuffer();490 for (const e of envs) await buf.add(e);491 const reconstructed = await reconstructAndVerify(buf);492 expect(reconstructed.vaultFingerprint).toBe(fp32);493 });494495 it("missing chunks but enough parity still reconstructs", async () => {496 const payload = await makePayload(5000);497 const envs = await buildEnvelopesForPayload(payload);498 if (envs.length <= 3) return; 499 const buf = new ChunkBuffer();500 501 for (let i = 1; i < envs.length; i++) await buf.add(envs[i]!);502 const reconstructed = await reconstructAndVerify(buf);503 expect(reconstructed.vaultFingerprint).toBe(payload.vaultFingerprint);504 });505506 it("tampered chunk data (after acceptance) does not round-trip", async () => {507 const payload = await makePayload(3000);508 const envs = await buildEnvelopesForPayload(payload);509 const buf = new ChunkBuffer();510 511 512 const evil = envs[0]!;513 const fakeBytes = crypto.getRandomValues(new Uint8Array(CHUNK_SIZE));514 const fakeHash = (await sha256Hex(fakeBytes)).slice(0, 32);515 const fakeEnv: ChunkEnvelope = {516 ...evil,517 d: bytesToBase64Url(fakeBytes),518 h: fakeHash,519 };520 await buf.add(fakeEnv);521 for (let i = 1; i < envs.length; i++) await buf.add(envs[i]!);522 523 await expect(reconstructAndVerify(buf)).rejects.toThrow(CodexError);524 });525});526527528529describe("parseRawScan with adversarial raw strings (no-crash)", () => {530 const inputs = [531 "{}",532 '{"a":1,"b":2}',533 '{"v":2}',534 '{"v":2,"c":""}',535 '{"v":2,"c":"' + "A".repeat(10000) + '"}',536 '{"v":2,"c":"PX-XXXX-XXXX","i":-1,"t":5,"p":false,"d":"","h":""}',537 '{"v":2,"c":"PX-XXXX-XXXX","i":99,"t":5,"p":false,"d":"","h":""}',538 '{"v":2,"c":"PX-XXXX-XXXX","i":0,"t":-5,"p":false,"d":"","h":""}',539 '{"v":2,"c":"PX-XXXX-XXXX","i":0,"t":5,"p":"yes","d":"","h":""}',540 '{"v":2,"c":"PX-XXXX-XXXX","i":0,"t":5,"p":false,"d":null,"h":null}',541 ];542 for (const raw of inputs) {543 it(`parseRawScan does not crash on ${raw.slice(0, 50)}`, () => {544 const result = parseRawScan(raw);545 expect(result).toBeDefined();546 });547 }548});549550551552describe("ChunkBuffer verify code adversarial", () => {553 const foreignCodes = [554 "PX-OTHR-1234",555 "PX-AAAA-AAAA",556 "PX-1111-1111",557 "PX-9999-9999",558 "PX-ABCD-EFGH",559 ];560 for (const code of foreignCodes) {561 it(`buffer rejects foreign code ${code}`, async () => {562 const buf = new ChunkBuffer();563 await buf.add(await makeEnv({ index: 0, total: 5 }));564 const foreign = await makeEnv({ index: 1, total: 5, verifyCode: code });565 const res = await buf.add(foreign);566 expect(res.accepted).toBe(false);567 expect(res.reason).toBe("foreign_codex");568 });569 }570});571572573574describe("erasure round-trip size sweep", () => {575 for (const size of [576 0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1023, 1024, 1025, 1500, 2048,577 3000, 4096, 5000, 7500, 10000,578 ]) {579 it(`size ${size} round-trips correctly`, async () => {580 const data = crypto.getRandomValues(new Uint8Array(size));581 const { dataChunks, parityChunks, dataShards, totalChunks } =582 await encodeWithErasure(data);583 const map = new Map<number, Uint8Array>();584 [...dataChunks, ...parityChunks].forEach((c, i) => map.set(i, c));585 const decoded = await decodeWithErasure(map, dataShards, totalChunks);586 for (let i = 0; i < size; i++) {587 expect(decoded[i]).toBe(data[i]);588 }589 });590591 it(`size ${size} drop-first-parity still decodes`, async () => {592 const data = crypto.getRandomValues(new Uint8Array(size));593 const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(594 data595 );596 const map = new Map<number, Uint8Array>();597 dataChunks.forEach((c, i) => map.set(i, c));598 599 const decoded = await decodeWithErasure(map, dataShards, totalChunks);600 for (let i = 0; i < size; i++) {601 expect(decoded[i]).toBe(data[i]);602 }603 });604 }605});606607608609describe("ChunkBuffer scale", () => {610 for (const total of [3, 5, 10]) {611 const dataShards = deriveShardCounts(total).dataShards;612 it(`buffer accepts ${dataShards} data chunks and reports ready`, async () => {613 const buf = new ChunkBuffer();614 for (let i = 0; i < dataShards; i++) {615 const res = await buf.add(await makeEnv({ index: i, total }));616 expect(res.accepted).toBe(true);617 }618 expect(buf.isReady()).toBe(true);619 });620 }621});622623624625describe("Codex integrity prefix length (AUD-004)", () => {626 it("chunk hash is 32 hex chars (128 bits)", async () => {627 const bytes = new Uint8Array([1, 2, 3, 4, 5]);628 const env = await buildChunkEnvelope(bytes, 0, 10, false, "PX-TEST-1234");629 expect(env.h).toHaveLength(32);630 expect(env.h).toMatch(/^[0-9A-F]{32}$/);631 });632633 it("verifyChunkIntegrity passes for current 32-char hash", async () => {634 const bytes = new Uint8Array([1, 2, 3]);635 const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");636 expect(env.h).toHaveLength(32);637 expect(await verifyChunkIntegrity(env)).toBe(true);638 });639640 it("verifyChunkIntegrity rejects tampered 32-char hash", async () => {641 const bytes = new Uint8Array([1, 2, 3]);642 const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");643 const tampered = { ...env, h: "X".repeat(32) };644 expect(await verifyChunkIntegrity(tampered)).toBe(false);645 });646647});