12345678910import { beforeEach, describe, expect, it } from "vitest";11import { deflate } from "pako";1213import {14 CodexError,15 CHUNK_SIZE,16 PARITY_SHARDS,17 type ChunkEnvelope,18 type CodexPayload,19} from "../../shared/codex-types";20import {21 bytesToBase64Url,22 buildChunkEnvelope,23 sha256Hex,24 verifyChunkIntegrity,25} from "./helpers";26import { encodeWithErasure, decodeWithErasure } from "./erasure";27import { _resetReedSolomonCacheForTest } from "./rs-loader";28import { ChunkBuffer, parseRawScan, reconstructAndVerify } from "./restore";2930beforeEach(() => {31 _resetReedSolomonCacheForTest();32});3334const VERIFY_CODE = "PX-TEST-1234";3536async function makeEnv(opts: {37 index: number;38 total: number;39 isParity?: boolean;40 verifyCode?: string;41 bytes?: Uint8Array;42}): Promise<ChunkEnvelope> {43 return buildChunkEnvelope(44 opts.bytes ?? new Uint8Array([1, 2, 3, 4, 5]),45 opts.index,46 opts.total,47 opts.isParity ?? false,48 opts.verifyCode ?? VERIFY_CODE49 );50}5152async function makePayload(seedLength = 12_000): Promise<CodexPayload> {53 const blobBytes = new Uint8Array(seedLength);54 let s = 0x9e3779b9;55 for (let i = 0; i < seedLength; i++) {56 s = (s ^ (s << 13)) >>> 0;57 s = (s ^ (s >>> 17)) >>> 0;58 s = (s ^ (s << 5)) >>> 0;59 blobBytes[i] = s & 0xff;60 }61 const vaultBlob = bytesToBase64Url(blobBytes);62 const vaultFingerprint = (63 await sha256Hex(new TextEncoder().encode(vaultBlob))64 )65 .slice(0, 16)66 .toUpperCase();67 return {68 version: 2,69 verifyCode: VERIFY_CODE,70 vaultBlob,71 timestamp: 1_700_000_000_000,72 entryCount: 42,73 vaultFingerprint,74 generatedAt: 1_700_000_001_000,75 };76}7778async function buildEnvelopesForPayload(79 payload: CodexPayload80): Promise<ChunkEnvelope[]> {81 const json = JSON.stringify(payload);82 const compressed = deflate(new TextEncoder().encode(json), { level: 9 });83 const { dataChunks, parityChunks, totalChunks } =84 await encodeWithErasure(compressed);85 const all = [...dataChunks, ...parityChunks];86 return Promise.all(87 all.map((bytes, i) =>88 buildChunkEnvelope(89 bytes,90 i,91 totalChunks,92 i >= dataChunks.length,93 payload.verifyCode94 )95 )96 );97}9899100101describe("parseRawScan adversarial", () => {102 const garbageInputs = [103 "",104 " ",105 "\n",106 "not json at all",107 "{not_valid_json",108 "null",109 "true",110 "42",111 '"a string"',112 "[]",113 "[1,2,3]",114 ];115 for (const raw of garbageInputs) {116 it(`returns parse_error for ${JSON.stringify(raw)}`, () => {117 const result = parseRawScan(raw);118 expect(result).toHaveProperty("error");119 });120 }121122 const wrongVersionInputs = [123 JSON.stringify({ v: 1, c: VERIFY_CODE, i: 0, t: 5 }),124 JSON.stringify({ v: 3, c: VERIFY_CODE, i: 0, t: 5 }),125 JSON.stringify({ v: 0, c: VERIFY_CODE, i: 0, t: 5 }),126 JSON.stringify({ v: "2", c: VERIFY_CODE, i: 0, t: 5 }),127 JSON.stringify({ v: -1 }),128 JSON.stringify({ v: 99 }),129 ];130 for (const raw of wrongVersionInputs) {131 it(`reports wrong_version for ${JSON.stringify(raw).slice(0, 40)}`, () => {132 const result = parseRawScan(raw);133 expect(result).toMatchObject({ error: "wrong_version" });134 });135 }136137 const missingFieldInputs = [138 JSON.stringify({ v: 2 }),139 JSON.stringify({ v: 2, c: VERIFY_CODE }),140 JSON.stringify({ v: 2, c: VERIFY_CODE, i: 0 }),141 JSON.stringify({ v: 2, c: VERIFY_CODE, i: 0, t: 5 }),142 JSON.stringify({ v: 2, c: VERIFY_CODE, i: 0, t: 5, p: false }),143 JSON.stringify({ v: 2, c: VERIFY_CODE, i: 0, t: 5, p: false, d: "x" }),144 ];145 for (const raw of missingFieldInputs) {146 it(`parse_error for missing fields: ${raw.slice(0, 60)}`, () => {147 const result = parseRawScan(raw);148 expect(result).toMatchObject({ error: "parse_error" });149 });150 }151152 it("returns envelope for a valid envelope JSON", async () => {153 const env = await makeEnv({ index: 0, total: 5 });154 const result = parseRawScan(JSON.stringify(env));155 expect(result).toMatchObject({ v: 2, c: VERIFY_CODE, i: 0, t: 5 });156 });157158 it("extra unknown fields do not break parseRawScan", async () => {159 const env = await makeEnv({ index: 0, total: 5 });160 const raw = JSON.stringify({ ...env, extra: "field", more: 42 });161 const result = parseRawScan(raw);162 expect(result).toMatchObject({ v: 2, c: VERIFY_CODE });163 });164});165166167168describe("ChunkBuffer adversarial", () => {169 it("foreign verifyCode is rejected as foreign_codex", async () => {170 const buf = new ChunkBuffer();171 await buf.add(await makeEnv({ index: 0, total: 5 }));172 const res = await buf.add(173 await makeEnv({ index: 1, total: 5, verifyCode: "PX-OTHR-XXXX" })174 );175 expect(res).toEqual({ accepted: false, reason: "foreign_codex" });176 });177178 it("tampered hash is rejected as bad_integrity", async () => {179 const buf = new ChunkBuffer();180 const env = await makeEnv({ index: 0, total: 5 });181 const tampered = { ...env, h: "00000000" };182 const res = await buf.add(tampered);183 expect(res).toEqual({ accepted: false, reason: "bad_integrity" });184 });185186 it("data tampered without rehashing is rejected", async () => {187 const buf = new ChunkBuffer();188 const env = await makeEnv({ index: 0, total: 5 });189 const tampered: ChunkEnvelope = {190 ...env,191 d: bytesToBase64Url(new Uint8Array([99, 99, 99])),192 };193 const res = await buf.add(tampered);194 expect(res).toEqual({ accepted: false, reason: "bad_integrity" });195 });196197 it("wrong envelope version is rejected as parse_error", async () => {198 const buf = new ChunkBuffer();199 const env = await makeEnv({ index: 0, total: 5 });200 const wrong = { ...env, v: 1 } as unknown as ChunkEnvelope;201 const res = await buf.add(wrong);202 expect(res).toEqual({ accepted: false, reason: "parse_error" });203 });204205 it("duplicate chunk index is dedup'd", async () => {206 const buf = new ChunkBuffer();207 const env = await makeEnv({ index: 2, total: 5 });208 expect((await buf.add(env)).accepted).toBe(true);209 const second = await buf.add(env);210 expect(second).toEqual({ accepted: false, reason: "duplicate" });211 expect(buf.size()).toBe(1);212 });213214 for (const idx of [0, 1, 2, 3, 4]) {215 it(`accepts chunk at index ${idx}`, async () => {216 const buf = new ChunkBuffer();217 const env = await makeEnv({ index: idx, total: 5 });218 const res = await buf.add(env);219 expect(res.accepted).toBe(true);220 });221 }222223 for (const total of [3, 5, 7, 10, 20]) {224 it(`context dataShards = total - PARITY_SHARDS at total=${total}`, async () => {225 const buf = new ChunkBuffer();226 await buf.add(await makeEnv({ index: 0, total }));227 const ctx = buf.getContext();228 expect(ctx?.dataShards).toBe(total - PARITY_SHARDS);229 });230 }231232 it("reconstructAndVerify on empty buffer throws CodexError", async () => {233 const buf = new ChunkBuffer();234 await expect(reconstructAndVerify(buf)).rejects.toThrow(CodexError);235 });236});237238239240describe("verifyChunkIntegrity adversarial", () => {241 it("correct envelope verifies true", async () => {242 const env = await makeEnv({ index: 0, total: 5 });243 expect(await verifyChunkIntegrity(env)).toBe(true);244 });245246 const corruptHashes = [247 "00000000",248 "FFFFFFFF",249 "ZZZZZZZZ",250 "abcdef12",251 "",252 ];253 for (const h of corruptHashes) {254 it(`tampered hash ${JSON.stringify(h)} verifies false`, async () => {255 const env = await makeEnv({ index: 0, total: 5 });256 const tampered = { ...env, h };257 expect(await verifyChunkIntegrity(tampered)).toBe(false);258 });259 }260261 for (let i = 0; i < 5; i++) {262 it(`random data tamper #${i} verifies false`, async () => {263 const env = await makeEnv({ index: 0, total: 5 });264 const bytes = new Uint8Array([265 Math.floor(Math.random() * 256),266 Math.floor(Math.random() * 256),267 Math.floor(Math.random() * 256),268 ]);269 const tampered = { ...env, d: bytesToBase64Url(bytes) };270 271 const recomputed = (await sha256Hex(bytes)).slice(0, 16);272 if (recomputed === env.h) return; 273 expect(await verifyChunkIntegrity(tampered)).toBe(false);274 });275 }276});277278279280describe("encodeWithErasure / decodeWithErasure adversarial", () => {281 it("encoding empty input still produces at least 1 data shard", async () => {282 const { dataChunks, parityChunks, totalChunks, dataShards } =283 await encodeWithErasure(new Uint8Array());284 expect(dataShards).toBeGreaterThanOrEqual(1);285 expect(totalChunks).toBe(dataShards + PARITY_SHARDS);286 expect(dataChunks.length).toBe(dataShards);287 expect(parityChunks.length).toBe(PARITY_SHARDS);288 });289290 for (const size of [1, 100, 1023, 1024, 1025, 5000, 10000]) {291 it(`encode/decode round-trips data of size ${size}`, async () => {292 const data = crypto.getRandomValues(new Uint8Array(size));293 const { dataChunks, parityChunks, dataShards, totalChunks } =294 await encodeWithErasure(data);295 const map = new Map<number, Uint8Array>();296 [...dataChunks, ...parityChunks].forEach((c, i) => map.set(i, c));297 const decoded = await decodeWithErasure(map, dataShards, totalChunks);298 expect(decoded.length).toBe(dataShards * CHUNK_SIZE);299 300 for (let i = 0; i < size; i++) {301 expect(decoded[i]).toBe(data[i]);302 }303 });304 }305306 it("decoding with empty chunk map throws CodexError", async () => {307 await expect(308 decodeWithErasure(new Map(), 5, 7)309 ).rejects.toThrow(CodexError);310 });311312 it("decoding with fewer chunks than dataShards throws CodexError", async () => {313 const data = crypto.getRandomValues(new Uint8Array(5000));314 const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(315 data316 );317 318 const map = new Map<number, Uint8Array>();319 map.set(0, dataChunks[0]!);320 await expect(321 decodeWithErasure(map, dataShards, totalChunks)322 ).rejects.toThrow(CodexError);323 });324325 it("chunk index out of range throws CodexError", async () => {326 const data = crypto.getRandomValues(new Uint8Array(2048));327 const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(328 data329 );330 const map = new Map<number, Uint8Array>();331 map.set(999, dataChunks[0]!);332 await expect(333 decodeWithErasure(map, dataShards, totalChunks)334 ).rejects.toThrow(CodexError);335 });336337 it("chunk with wrong size throws CodexError", async () => {338 const data = crypto.getRandomValues(new Uint8Array(2048));339 const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(340 data341 );342 const map = new Map<number, Uint8Array>();343 344 map.set(0, new Uint8Array(100));345 for (let i = 1; i < dataShards; i++) map.set(i, dataChunks[i]!);346 await expect(347 decodeWithErasure(map, dataShards, totalChunks)348 ).rejects.toThrow(CodexError);349 });350351 it("dropping 1 data chunk (within parity budget) reconstructs", async () => {352 const data = crypto.getRandomValues(new Uint8Array(8000));353 const { dataChunks, parityChunks, dataShards, totalChunks } =354 await encodeWithErasure(data);355 const map = new Map<number, Uint8Array>();356 357 for (let i = 1; i < dataShards; i++) map.set(i, dataChunks[i]!);358 parityChunks.forEach((c, j) => map.set(dataShards + j, c));359 const decoded = await decodeWithErasure(map, dataShards, totalChunks);360 expect(decoded.length).toBe(dataShards * CHUNK_SIZE);361 for (let i = 0; i < data.length; i++) {362 expect(decoded[i]).toBe(data[i]);363 }364 });365366 it("dropping all data chunks (only parity present) fails when k < dataShards", async () => {367 const data = crypto.getRandomValues(new Uint8Array(8000));368 const { parityChunks, dataShards, totalChunks } = await encodeWithErasure(369 data370 );371 372 if (parityChunks.length >= dataShards) return;373 const map = new Map<number, Uint8Array>();374 parityChunks.forEach((c, j) => map.set(dataShards + j, c));375 await expect(376 decodeWithErasure(map, dataShards, totalChunks)377 ).rejects.toThrow(CodexError);378 });379380 it("dropping too many data chunks (more than parity) fails cleanly", async () => {381 const data = crypto.getRandomValues(new Uint8Array(20000));382 const { dataChunks, parityChunks, dataShards, totalChunks } =383 await encodeWithErasure(data);384 if (dataShards <= PARITY_SHARDS + 1) return; 385 const map = new Map<number, Uint8Array>();386 387 for (let i = PARITY_SHARDS + 1; i < dataShards; i++) {388 map.set(i, dataChunks[i]!);389 }390 parityChunks.forEach((c, j) => map.set(dataShards + j, c));391 await expect(392 decodeWithErasure(map, dataShards, totalChunks)393 ).rejects.toThrow(CodexError);394 });395});396397398399describe("reconstructAndVerify adversarial", () => {400 it("round-trips a payload via ChunkBuffer + reconstructAndVerify", async () => {401 const payload = await makePayload(2000);402 const envs = await buildEnvelopesForPayload(payload);403 const buf = new ChunkBuffer();404 for (const e of envs) await buf.add(e);405 const reconstructed = await reconstructAndVerify(buf);406 expect(reconstructed.vaultFingerprint).toBe(payload.vaultFingerprint);407 expect(reconstructed.entryCount).toBe(payload.entryCount);408 });409410 it("missing chunks but enough parity still reconstructs", async () => {411 const payload = await makePayload(5000);412 const envs = await buildEnvelopesForPayload(payload);413 if (envs.length <= PARITY_SHARDS + 1) return;414 const buf = new ChunkBuffer();415 416 for (let i = 1; i < envs.length; i++) await buf.add(envs[i]!);417 const reconstructed = await reconstructAndVerify(buf);418 expect(reconstructed.vaultFingerprint).toBe(payload.vaultFingerprint);419 });420421 it("tampered chunk data (after acceptance) does not round-trip", async () => {422 const payload = await makePayload(3000);423 const envs = await buildEnvelopesForPayload(payload);424 const buf = new ChunkBuffer();425 426 427 const evil = envs[0]!;428 const fakeBytes = crypto.getRandomValues(new Uint8Array(CHUNK_SIZE));429 const fakeHash = (await sha256Hex(fakeBytes)).slice(0, 16);430 const fakeEnv: ChunkEnvelope = {431 ...evil,432 d: bytesToBase64Url(fakeBytes),433 h: fakeHash,434 };435 await buf.add(fakeEnv);436 for (let i = 1; i < envs.length; i++) await buf.add(envs[i]!);437 438 await expect(reconstructAndVerify(buf)).rejects.toThrow(CodexError);439 });440});441442443444describe("parseRawScan with adversarial raw strings (no-crash)", () => {445 const inputs = [446 "{}",447 '{"a":1,"b":2}',448 '{"v":2}',449 '{"v":2,"c":""}',450 '{"v":2,"c":"' + "A".repeat(10000) + '"}',451 '{"v":2,"c":"PX-XXXX-XXXX","i":-1,"t":5,"p":false,"d":"","h":""}',452 '{"v":2,"c":"PX-XXXX-XXXX","i":99,"t":5,"p":false,"d":"","h":""}',453 '{"v":2,"c":"PX-XXXX-XXXX","i":0,"t":-5,"p":false,"d":"","h":""}',454 '{"v":2,"c":"PX-XXXX-XXXX","i":0,"t":5,"p":"yes","d":"","h":""}',455 '{"v":2,"c":"PX-XXXX-XXXX","i":0,"t":5,"p":false,"d":null,"h":null}',456 ];457 for (const raw of inputs) {458 it(`parseRawScan does not crash on ${raw.slice(0, 50)}`, () => {459 const result = parseRawScan(raw);460 expect(result).toBeDefined();461 });462 }463});464465466467describe("ChunkBuffer verify code adversarial", () => {468 const foreignCodes = [469 "PX-OTHR-1234",470 "PX-AAAA-AAAA",471 "PX-1111-1111",472 "PX-9999-9999",473 "PX-ABCD-EFGH",474 ];475 for (const code of foreignCodes) {476 it(`buffer rejects foreign code ${code}`, async () => {477 const buf = new ChunkBuffer();478 await buf.add(await makeEnv({ index: 0, total: 5 }));479 const foreign = await makeEnv({ index: 1, total: 5, verifyCode: code });480 const res = await buf.add(foreign);481 expect(res.accepted).toBe(false);482 expect(res.reason).toBe("foreign_codex");483 });484 }485});486487488489describe("erasure round-trip size sweep", () => {490 for (const size of [491 0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1023, 1024, 1025, 1500, 2048,492 3000, 4096, 5000, 7500, 10000,493 ]) {494 it(`size ${size} round-trips correctly`, async () => {495 const data = crypto.getRandomValues(new Uint8Array(size));496 const { dataChunks, parityChunks, dataShards, totalChunks } =497 await encodeWithErasure(data);498 const map = new Map<number, Uint8Array>();499 [...dataChunks, ...parityChunks].forEach((c, i) => map.set(i, c));500 const decoded = await decodeWithErasure(map, dataShards, totalChunks);501 for (let i = 0; i < size; i++) {502 expect(decoded[i]).toBe(data[i]);503 }504 });505506 it(`size ${size} drop-first-parity still decodes`, async () => {507 const data = crypto.getRandomValues(new Uint8Array(size));508 const { dataChunks, dataShards, totalChunks } = await encodeWithErasure(509 data510 );511 const map = new Map<number, Uint8Array>();512 dataChunks.forEach((c, i) => map.set(i, c));513 514 const decoded = await decodeWithErasure(map, dataShards, totalChunks);515 for (let i = 0; i < size; i++) {516 expect(decoded[i]).toBe(data[i]);517 }518 });519 }520});521522523524describe("ChunkBuffer scale", () => {525 for (const total of [3, 5, 10]) {526 it(`buffer accepts ${total - PARITY_SHARDS} data chunks and reports ready`, async () => {527 const buf = new ChunkBuffer();528 const dataShards = total - PARITY_SHARDS;529 for (let i = 0; i < dataShards; i++) {530 const res = await buf.add(await makeEnv({ index: i, total }));531 expect(res.accepted).toBe(true);532 }533 expect(buf.isReady()).toBe(true);534 });535 }536});