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, 16)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: 2 }),140 JSON.stringify({ v: 2, c: VERIFY_CODE }),141 JSON.stringify({ v: 2, c: VERIFY_CODE, i: 0 }),142 JSON.stringify({ v: 2, c: VERIFY_CODE, i: 0, t: 5 }),143 JSON.stringify({ v: 2, c: VERIFY_CODE, i: 0, t: 5, p: false }),144 JSON.stringify({ v: 2, 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("tampered hash is rejected as bad_integrity", async () => {180 const buf = new ChunkBuffer();181 const env = await makeEnv({ index: 0, total: 5 });182 const tampered = { ...env, h: "00000000" };183 const res = await buf.add(tampered);184 expect(res).toEqual({ accepted: false, reason: "bad_integrity" });185 });186187 it("data tampered without rehashing is rejected", async () => {188 const buf = new ChunkBuffer();189 const env = await makeEnv({ index: 0, total: 5 });190 const tampered: ChunkEnvelope = {191 ...env,192 d: bytesToBase64Url(new Uint8Array([99, 99, 99])),193 };194 const res = await buf.add(tampered);195 expect(res).toEqual({ accepted: false, reason: "bad_integrity" });196 });197198 it("wrong envelope version is rejected as parse_error", async () => {199 const buf = new ChunkBuffer();200 const env = await makeEnv({ index: 0, total: 5 });201 const wrong = { ...env, v: 1 } as unknown as ChunkEnvelope;202 const res = await buf.add(wrong);203 expect(res).toEqual({ accepted: false, reason: "parse_error" });204 });205206 it("duplicate chunk index is dedup'd", async () => {207 const buf = new ChunkBuffer();208 const env = await makeEnv({ index: 2, total: 5 });209 expect((await buf.add(env)).accepted).toBe(true);210 const second = await buf.add(env);211 expect(second).toEqual({ accepted: false, reason: "duplicate" });212 expect(buf.size()).toBe(1);213 });214215 for (const idx of [0, 1, 2, 3, 4]) {216 it(`accepts chunk at index ${idx}`, async () => {217 const buf = new ChunkBuffer();218 const env = await makeEnv({ index: idx, total: 5 });219 const res = await buf.add(env);220 expect(res.accepted).toBe(true);221 });222 }223224 for (const total of [3, 5, 7, 10, 20]) {225 it(`context dataShards matches deriveShardCounts at total=${total}`, async () => {226 const buf = new ChunkBuffer();227 await buf.add(await makeEnv({ index: 0, total }));228 const ctx = buf.getContext();229 expect(ctx?.dataShards).toBe(deriveShardCounts(total).dataShards);230 });231 }232233 it("reconstructAndVerify on empty buffer throws CodexError", async () => {234 const buf = new ChunkBuffer();235 await expect(reconstructAndVerify(buf)).rejects.toThrow(CodexError);236 });237238 239 240 it("derive_shard_counts_rejects_absurd_total", () => {241 expect(() => deriveShardCounts(1_000_000_000)).toThrow(CodexError);242 });243244 it("derive_shard_counts_accepts_real_max", () => {245 246 const total = 200 + computeParityShards(200);247 const counts = deriveShardCounts(total);248 expect(counts.dataShards).toBe(200);249 });250});251252253254describe("verifyChunkIntegrity adversarial", () => {255 it("correct envelope verifies true", async () => {256 const env = await makeEnv({ index: 0, total: 5 });257 expect(await verifyChunkIntegrity(env)).toBe(true);258 });259260 const corruptHashes = [261 "00000000",262 "FFFFFFFF",263 "ZZZZZZZZ",264 "abcdef12",265 "",266 ];267 for (const h of corruptHashes) {268 it(`tampered hash ${JSON.stringify(h)} verifies false`, async () => {269 const env = await makeEnv({ index: 0, total: 5 });270 const tampered = { ...env, h };271 expect(await verifyChunkIntegrity(tampered)).toBe(false);272 });273 }274275 for (let i = 0; i < 5; i++) {276 it(`random data tamper #${i} verifies false`, async () => {277 const env = await makeEnv({ index: 0, total: 5 });278 const bytes = new Uint8Array([279 Math.floor(Math.random() * 256),280 Math.floor(Math.random() * 256),281 Math.floor(Math.random() * 256),282 ]);283 const tampered = { ...env, d: bytesToBase64Url(bytes) };284 285 const recomputed = (await sha256Hex(bytes)).slice(0, 16);286 if (recomputed === env.h) return; 287 expect(await verifyChunkIntegrity(tampered)).toBe(false);288 });289 }290});291292293294describe("encodeWithErasure / decodeWithErasure adversarial", () => {295 it("encoding empty input still produces at least 1 data shard", async () => {296 const { dataChunks, parityChunks, totalChunks, dataShards, parityShards } =297 await encodeWithErasure(new Uint8Array());298 expect(dataShards).toBeGreaterThanOrEqual(1);299 expect(parityShards).toBe(computeParityShards(dataShards));300 expect(totalChunks).toBe(dataShards + parityShards);301 expect(dataChunks.length).toBe(dataShards);302 expect(parityChunks.length).toBe(parityShards);303 });304305 for (const size of [1, 100, 1023, 1024, 1025, 5000, 10000]) {306 it(`encode/decode round-trips data of size ${size}`, async () => {307 const data = crypto.getRandomValues(new Uint8Array(size));308 const { dataChunks, parityChunks, dataShards, totalChunks } =309 await encodeWithErasure(data);310 const map = new Map<number, Uint8Array>();311 [...dataChunks, ...parityChunks].forEach((c, i) => map.set(i, c));312 const decoded = await decodeWithErasure(map, dataShards, totalChunks);313 expect(decoded.length).toBe(dataShards * CHUNK_SIZE);314 315 for (let i = 0; i < size; i++) {316 expect(decoded[i]).toBe(data[i]);317 }318 });319 }320321 it("decoding with empty chunk map throws CodexError", async () => {322 await expect(323 decodeWithErasure(new Map(), 5, 7)324 ).rejects.toThrow(CodexError);325 });326327 it("decoding with fewer chunks than dataShards throws CodexError", async () => {328 const data = crypto.getRandomValues(new Uint8Array(5000));329 const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(330 data331 );332 333 const map = new Map<number, Uint8Array>();334 map.set(0, dataChunks[0]!);335 await expect(336 decodeWithErasure(map, dataShards, totalChunks)337 ).rejects.toThrow(CodexError);338 });339340 it("chunk index out of range throws CodexError", async () => {341 const data = crypto.getRandomValues(new Uint8Array(2048));342 const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(343 data344 );345 const map = new Map<number, Uint8Array>();346 map.set(999, dataChunks[0]!);347 await expect(348 decodeWithErasure(map, dataShards, totalChunks)349 ).rejects.toThrow(CodexError);350 });351352 it("chunk with wrong size throws CodexError", async () => {353 const data = crypto.getRandomValues(new Uint8Array(2048));354 const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(355 data356 );357 const map = new Map<number, Uint8Array>();358 359 map.set(0, new Uint8Array(100));360 for (let i = 1; i < dataShards; i++) map.set(i, dataChunks[i]!);361 await expect(362 decodeWithErasure(map, dataShards, totalChunks)363 ).rejects.toThrow(CodexError);364 });365366 it("dropping 1 data chunk (within parity budget) reconstructs", async () => {367 const data = crypto.getRandomValues(new Uint8Array(8000));368 const { dataChunks, parityChunks, dataShards, totalChunks } =369 await encodeWithErasure(data);370 const map = new Map<number, Uint8Array>();371 372 for (let i = 1; i < dataShards; i++) map.set(i, dataChunks[i]!);373 parityChunks.forEach((c, j) => map.set(dataShards + j, c));374 const decoded = await decodeWithErasure(map, dataShards, totalChunks);375 expect(decoded.length).toBe(dataShards * CHUNK_SIZE);376 for (let i = 0; i < data.length; i++) {377 expect(decoded[i]).toBe(data[i]);378 }379 });380381 it("dropping all data chunks (only parity present) fails when k < dataShards", async () => {382 const data = crypto.getRandomValues(new Uint8Array(8000));383 const { parityChunks, dataShards, totalChunks } = await encodeWithErasure(384 data385 );386 387 if (parityChunks.length >= dataShards) return;388 const map = new Map<number, Uint8Array>();389 parityChunks.forEach((c, j) => map.set(dataShards + j, c));390 await expect(391 decodeWithErasure(map, dataShards, totalChunks)392 ).rejects.toThrow(CodexError);393 });394395 it("dropping too many data chunks (more than parity) fails cleanly", async () => {396 const data = crypto.getRandomValues(new Uint8Array(20000));397 const { dataChunks, parityChunks, dataShards, parityShards, totalChunks } =398 await encodeWithErasure(data);399 if (dataShards <= parityShards + 1) return; 400 const map = new Map<number, Uint8Array>();401 402 for (let i = parityShards + 1; i < dataShards; i++) {403 map.set(i, dataChunks[i]!);404 }405 parityChunks.forEach((c, j) => map.set(dataShards + j, c));406 await expect(407 decodeWithErasure(map, dataShards, totalChunks)408 ).rejects.toThrow(CodexError);409 });410});411412413414describe("reconstructAndVerify adversarial", () => {415 it("round-trips a payload via ChunkBuffer + reconstructAndVerify", async () => {416 const payload = await makePayload(2000);417 const envs = await buildEnvelopesForPayload(payload);418 const buf = new ChunkBuffer();419 for (const e of envs) await buf.add(e);420 const reconstructed = await reconstructAndVerify(buf);421 expect(reconstructed.vaultFingerprint).toBe(payload.vaultFingerprint);422 expect(reconstructed.entryCount).toBe(payload.entryCount);423 });424425 it("missing chunks but enough parity still reconstructs", async () => {426 const payload = await makePayload(5000);427 const envs = await buildEnvelopesForPayload(payload);428 if (envs.length <= 3) return; 429 const buf = new ChunkBuffer();430 431 for (let i = 1; i < envs.length; i++) await buf.add(envs[i]!);432 const reconstructed = await reconstructAndVerify(buf);433 expect(reconstructed.vaultFingerprint).toBe(payload.vaultFingerprint);434 });435436 it("tampered chunk data (after acceptance) does not round-trip", async () => {437 const payload = await makePayload(3000);438 const envs = await buildEnvelopesForPayload(payload);439 const buf = new ChunkBuffer();440 441 442 const evil = envs[0]!;443 const fakeBytes = crypto.getRandomValues(new Uint8Array(CHUNK_SIZE));444 const fakeHash = (await sha256Hex(fakeBytes)).slice(0, 16);445 const fakeEnv: ChunkEnvelope = {446 ...evil,447 d: bytesToBase64Url(fakeBytes),448 h: fakeHash,449 };450 await buf.add(fakeEnv);451 for (let i = 1; i < envs.length; i++) await buf.add(envs[i]!);452 453 await expect(reconstructAndVerify(buf)).rejects.toThrow(CodexError);454 });455});456457458459describe("parseRawScan with adversarial raw strings (no-crash)", () => {460 const inputs = [461 "{}",462 '{"a":1,"b":2}',463 '{"v":2}',464 '{"v":2,"c":""}',465 '{"v":2,"c":"' + "A".repeat(10000) + '"}',466 '{"v":2,"c":"PX-XXXX-XXXX","i":-1,"t":5,"p":false,"d":"","h":""}',467 '{"v":2,"c":"PX-XXXX-XXXX","i":99,"t":5,"p":false,"d":"","h":""}',468 '{"v":2,"c":"PX-XXXX-XXXX","i":0,"t":-5,"p":false,"d":"","h":""}',469 '{"v":2,"c":"PX-XXXX-XXXX","i":0,"t":5,"p":"yes","d":"","h":""}',470 '{"v":2,"c":"PX-XXXX-XXXX","i":0,"t":5,"p":false,"d":null,"h":null}',471 ];472 for (const raw of inputs) {473 it(`parseRawScan does not crash on ${raw.slice(0, 50)}`, () => {474 const result = parseRawScan(raw);475 expect(result).toBeDefined();476 });477 }478});479480481482describe("ChunkBuffer verify code adversarial", () => {483 const foreignCodes = [484 "PX-OTHR-1234",485 "PX-AAAA-AAAA",486 "PX-1111-1111",487 "PX-9999-9999",488 "PX-ABCD-EFGH",489 ];490 for (const code of foreignCodes) {491 it(`buffer rejects foreign code ${code}`, async () => {492 const buf = new ChunkBuffer();493 await buf.add(await makeEnv({ index: 0, total: 5 }));494 const foreign = await makeEnv({ index: 1, total: 5, verifyCode: code });495 const res = await buf.add(foreign);496 expect(res.accepted).toBe(false);497 expect(res.reason).toBe("foreign_codex");498 });499 }500});501502503504describe("erasure round-trip size sweep", () => {505 for (const size of [506 0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1023, 1024, 1025, 1500, 2048,507 3000, 4096, 5000, 7500, 10000,508 ]) {509 it(`size ${size} round-trips correctly`, async () => {510 const data = crypto.getRandomValues(new Uint8Array(size));511 const { dataChunks, parityChunks, dataShards, totalChunks } =512 await encodeWithErasure(data);513 const map = new Map<number, Uint8Array>();514 [...dataChunks, ...parityChunks].forEach((c, i) => map.set(i, c));515 const decoded = await decodeWithErasure(map, dataShards, totalChunks);516 for (let i = 0; i < size; i++) {517 expect(decoded[i]).toBe(data[i]);518 }519 });520521 it(`size ${size} drop-first-parity still decodes`, async () => {522 const data = crypto.getRandomValues(new Uint8Array(size));523 const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(524 data525 );526 const map = new Map<number, Uint8Array>();527 dataChunks.forEach((c, i) => map.set(i, c));528 529 const decoded = await decodeWithErasure(map, dataShards, totalChunks);530 for (let i = 0; i < size; i++) {531 expect(decoded[i]).toBe(data[i]);532 }533 });534 }535});536537538539describe("ChunkBuffer scale", () => {540 for (const total of [3, 5, 10]) {541 const dataShards = deriveShardCounts(total).dataShards;542 it(`buffer accepts ${dataShards} data chunks and reports ready`, async () => {543 const buf = new ChunkBuffer();544 for (let i = 0; i < dataShards; i++) {545 const res = await buf.add(await makeEnv({ index: i, total }));546 expect(res.accepted).toBe(true);547 }548 expect(buf.isReady()).toBe(true);549 });550 }551});552553554555describe("Codex integrity prefix length (AUD-004)", () => {556 it("chunk hash is 32 hex chars (128 bits)", async () => {557 const bytes = new Uint8Array([1, 2, 3, 4, 5]);558 const env = await buildChunkEnvelope(bytes, 0, 10, false, "PX-TEST-1234");559 expect(env.h).toHaveLength(32);560 expect(env.h).toMatch(/^[0-9A-F]{32}$/);561 });562563 it("verifyChunkIntegrity passes for current 32-char hash", async () => {564 const bytes = new Uint8Array([1, 2, 3]);565 const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");566 expect(env.h).toHaveLength(32);567 expect(await verifyChunkIntegrity(env)).toBe(true);568 });569570 it("verifyChunkIntegrity still passes for legacy 16-char hash", async () => {571 const bytes = new Uint8Array([1, 2, 3]);572 const fullHash = await sha256Hex(bytes);573 const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");574 const legacy = { ...env, h: fullHash.slice(0, 16) };575 expect(await verifyChunkIntegrity(legacy)).toBe(true);576 });577578 it("verifyChunkIntegrity rejects tampered 32-char hash", async () => {579 const bytes = new Uint8Array([1, 2, 3]);580 const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");581 const tampered = { ...env, h: "X".repeat(32) };582 expect(await verifyChunkIntegrity(tampered)).toBe(false);583 });584585 it("verifyChunkIntegrity rejects tampered 16-char legacy hash", async () => {586 const bytes = new Uint8Array([1, 2, 3]);587 const env = await buildChunkEnvelope(bytes, 0, 5, false, "PX-TEST-1234");588 const tampered = { ...env, h: "X".repeat(16) };589 expect(await verifyChunkIntegrity(tampered)).toBe(false);590 });591});