123456789101112131415161718192021222324import {25 CHUNK_SIZE,26 CodexError,27 CODEX_MAX_PARITY_SHARDS,28 deriveShardCounts,29 MAX_DATA_SHARDS_PER_STRIPE,30 MAX_STRIPE_COUNT,31 type ChunkEnvelope,32 type CodexPayload,33} from "../../shared/codex-types";34import { reconstructPayloadFromStripes, type StripeChunks } from "./generate";35import {36 sha256Hex,37 tryBase64UrlToBytes,38 verifyChunkIntegrity,39} from "./helpers";4041424344454647export const MAX_RAW_SCAN_CHARS = 4096;484950export const MAX_CHUNK_B64_CHARS = Math.ceil(CHUNK_SIZE / 3) * 4;515253export const SCAN_CHUNK_HEADROOM_BYTES = 64;545556export const MAX_DECODED_CHUNK_BYTES = CHUNK_SIZE + SCAN_CHUNK_HEADROOM_BYTES;575859export const VERIFY_CODE_LENGTH = 12;60export const CHUNK_HASH_LENGTH = 32;616263export const MAX_SHARDS_PER_STRIPE =64 MAX_DATA_SHARDS_PER_STRIPE + CODEX_MAX_PARITY_SHARDS;656667export const MAX_BUFFER_CHUNKS = MAX_STRIPE_COUNT * MAX_SHARDS_PER_STRIPE;686970export const MAX_BUFFER_BYTES = MAX_BUFFER_CHUNKS * CHUNK_SIZE;7172export interface ScannedChunk {73 envelope: ChunkEnvelope;74 bytes: Uint8Array;75}7677export type ChunkAddReason =78 | "duplicate"79 | "foreign_codex"80 | "bad_integrity"81 | "parse_error";8283export interface ChunkAddResult {84 accepted: boolean;85 reason?: ChunkAddReason;86}8788interface StripeState {89 total: number;90 dataShards: number;91 chunks: Map<number, ScannedChunk>;92}93949596979899100101102export interface ChunkBufferContext {103 verifyCode: string;104 105 stripeCount: number;106 107 total: number;108 109 dataShards: number;110 111 scanned: number;112 113 allStripesObserved: boolean;114}115116export interface StripeReadiness {117 stripe: number;118 found: number;119 required: number;120 isReady: boolean;121}122123export class ChunkBuffer {124 private verifyCode: string | null = null;125 private stripeCount: number | null = null;126 private readonly stripes = new Map<number, StripeState>();127 private retainedBytes = 0;128 private readonly maxBytes: number;129 private readonly maxChunks: number;130131 constructor(limits?: { maxBytes?: number; maxChunks?: number }) {132 this.maxBytes = limits?.maxBytes ?? MAX_BUFFER_BYTES;133 this.maxChunks = limits?.maxChunks ?? MAX_BUFFER_CHUNKS;134 }135136 137138139140141142143144145146147 async add(envelope: ChunkEnvelope): Promise<ChunkAddResult> {148 if (envelope.v !== 3) {149 return { accepted: false, reason: "parse_error" };150 }151152 153 154 155 if (156 typeof envelope.c !== "string" ||157 envelope.c.length !== VERIFY_CODE_LENGTH ||158 typeof envelope.h !== "string" ||159 envelope.h.length !== CHUNK_HASH_LENGTH ||160 typeof envelope.d !== "string" ||161 envelope.d.length === 0 ||162 envelope.d.length > MAX_CHUNK_B64_CHARS ||163 typeof envelope.p !== "boolean" ||164 !Number.isInteger(envelope.s) ||165 !Number.isInteger(envelope.S) ||166 envelope.S < 1 ||167 envelope.S > MAX_STRIPE_COUNT ||168 envelope.s < 0 ||169 envelope.s >= envelope.S ||170 !Number.isInteger(envelope.i) ||171 !Number.isInteger(envelope.t) ||172 envelope.t < 1 ||173 envelope.t > MAX_SHARDS_PER_STRIPE ||174 envelope.i < 0 ||175 envelope.i >= envelope.t176 ) {177 return { accepted: false, reason: "parse_error" };178 }179180 181 182 183 184 185 const decoded = tryBase64UrlToBytes(envelope.d);186 if (decoded === null || decoded.length > MAX_DECODED_CHUNK_BYTES) {187 return { accepted: false, reason: "parse_error" };188 }189190 if (!(await verifyChunkIntegrity(envelope))) {191 return { accepted: false, reason: "bad_integrity" };192 }193194 const stripe = envelope.s;195 const stripeCount = envelope.S;196197 if (this.verifyCode === null) {198 199 200 201 this.verifyCode = envelope.c;202 this.stripeCount = stripeCount;203 } else if (envelope.c !== this.verifyCode) {204 return { accepted: false, reason: "foreign_codex" };205 } else if (this.stripeCount !== stripeCount) {206 return { accepted: false, reason: "foreign_codex" };207 }208209 let stripeState = this.stripes.get(stripe);210 if (!stripeState) {211 212 213 214 215 let dataShards: number;216 try {217 dataShards = deriveShardCounts(envelope.t).dataShards;218 } catch {219 return { accepted: false, reason: "parse_error" };220 }221 stripeState = { total: envelope.t, dataShards, chunks: new Map() };222 this.stripes.set(stripe, stripeState);223 } else if (stripeState.total !== envelope.t) {224 225 226 return { accepted: false, reason: "parse_error" };227 }228229 230 231 if (stripeState.chunks.has(envelope.i)) {232 return { accepted: false, reason: "duplicate" };233 }234235 236 237 238 239 if (240 this.retainedBytes + decoded.length > this.maxBytes ||241 this.size() + 1 > this.maxChunks242 ) {243 return { accepted: false, reason: "parse_error" };244 }245246 stripeState.chunks.set(envelope.i, {247 envelope,248 bytes: decoded, 249 });250 this.retainedBytes += decoded.length;251 return { accepted: true };252 }253254 size(): number {255 let count = 0;256 for (const s of this.stripes.values()) count += s.chunks.size;257 return count;258 }259260 261 bufferedBytes(): number {262 return this.retainedBytes;263 }264265 getContext(): ChunkBufferContext | null {266 if (this.verifyCode === null || this.stripeCount === null) return null;267 let total = 0;268 let dataShards = 0;269 let scanned = 0;270 for (const s of this.stripes.values()) {271 total += s.total;272 dataShards += s.dataShards;273 scanned += s.chunks.size;274 }275 return {276 verifyCode: this.verifyCode,277 stripeCount: this.stripeCount,278 total,279 dataShards,280 scanned,281 allStripesObserved: this.stripes.size === this.stripeCount,282 };283 }284285 isReady(): boolean {286 if (this.verifyCode === null || this.stripeCount === null) return false;287 if (this.stripes.size < this.stripeCount) return false;288 for (let i = 0; i < this.stripeCount; i++) {289 const s = this.stripes.get(i);290 if (!s || s.chunks.size < s.dataShards) return false;291 }292 return true;293 }294295 stripeReadiness(): StripeReadiness[] {296 if (this.stripeCount === null) return [];297 const out: StripeReadiness[] = [];298 for (let i = 0; i < this.stripeCount; i++) {299 const s = this.stripes.get(i);300 const found = s?.chunks.size ?? 0;301 const required = s?.dataShards ?? 0;302 out.push({303 stripe: i,304 found,305 required,306 isReady: !!s && found >= required,307 });308 }309 return out;310 }311312 entries(): ScannedChunk[] {313 const out: ScannedChunk[] = [];314 for (const s of this.stripes.values()) {315 for (const c of s.chunks.values()) out.push(c);316 }317 return out;318 }319320 321 toStripeChunks(): StripeChunks[] {322 if (this.stripeCount === null) return [];323 const out: StripeChunks[] = [];324 for (let i = 0; i < this.stripeCount; i++) {325 const s = this.stripes.get(i);326 if (!s) {327 throw new CodexError(328 "INSUFFICIENT_CHUNKS",329 `No envelopes scanned from stripe ${i} of ${this.stripeCount}.`,330 );331 }332 const chunks = new Map<number, Uint8Array>();333 for (const [idx, scanned] of s.chunks) chunks.set(idx, scanned.bytes);334 out.push({ chunks, dataShards: s.dataShards, totalChunks: s.total });335 }336 return out;337 }338}339340export type ParseRawScanResult =341 | ChunkEnvelope342 | { error: "parse_error" | "wrong_version" };343344345346347348349350351352353354355export function parseRawScan(raw: string): ParseRawScanResult {356 if (typeof raw !== "string" || raw.length > MAX_RAW_SCAN_CHARS) {357 return { error: "parse_error" };358 }359360 let parsed: unknown;361 try {362 parsed = JSON.parse(raw);363 } catch {364 return { error: "parse_error" };365 }366367 if (typeof parsed !== "object" || parsed === null) {368 return { error: "parse_error" };369 }370371 const obj = parsed as Record<string, unknown>;372373 if (obj.v !== 3) {374 if ("v" in obj) return { error: "wrong_version" };375 return { error: "parse_error" };376 }377378 const looksLikeEnvelope =379 typeof obj.c === "string" &&380 obj.c.length === VERIFY_CODE_LENGTH &&381 Number.isInteger(obj.i) && 382 Number.isInteger(obj.t) &&383 typeof obj.p === "boolean" &&384 typeof obj.d === "string" &&385 (obj.d as string).length > 0 &&386 (obj.d as string).length <= MAX_CHUNK_B64_CHARS &&387 typeof obj.h === "string" &&388 (obj.h as string).length === CHUNK_HASH_LENGTH;389390 if (!looksLikeEnvelope) {391 return { error: "parse_error" };392 }393394 if (!Number.isInteger(obj.s) || !Number.isInteger(obj.S)) {395 return { error: "parse_error" };396 }397398 const s = obj.s as number;399 const S = obj.S as number;400 const i = obj.i as number;401 const t = obj.t as number;402 if (403 S < 1 ||404 S > MAX_STRIPE_COUNT ||405 s < 0 ||406 s >= S ||407 t < 1 ||408 t > MAX_SHARDS_PER_STRIPE ||409 i < 0 ||410 i >= t411 ) {412 return { error: "parse_error" };413 }414415 return obj as unknown as ChunkEnvelope;416}417418419420421422423424425export function createScanGate(426 process: (raw: string) => Promise<void>,427): { onScan: (raw: string) => void } {428 let busy = false;429 return {430 onScan(raw: string) {431 if (busy) return;432 busy = true;433 void process(raw).then(434 () => {435 busy = false;436 },437 () => {438 busy = false;439 },440 );441 },442 };443}444445446447448449450export async function reconstructAndVerify(451 buffer: ChunkBuffer,452): Promise<CodexPayload> {453 const ctx = buffer.getContext();454 if (ctx === null) {455 throw new CodexError(456 "INSUFFICIENT_CHUNKS",457 "No chunks have been scanned yet.",458 );459 }460461 const payload = await reconstructPayloadFromStripes(buffer.toStripeChunks());462463 464 465 466 467 468 469 470 if (payload.vaultFingerprint.length !== 32) {471 throw new CodexError(472 "RECONSTRUCTION_FINGERPRINT_MISMATCH",473 "Reconstructed vault fingerprint has an invalid length. " +474 "Try scanning the Codex again from the beginning.",475 );476 }477 const fingerprint = (await sha256Hex(new TextEncoder().encode(payload.vaultBlob)))478 .slice(0, 32)479 .toUpperCase();480 if (fingerprint !== payload.vaultFingerprint) {481 throw new CodexError(482 "RECONSTRUCTION_FINGERPRINT_MISMATCH",483 "Reconstructed vault fingerprint does not match the printed Codex. " +484 "Try scanning the Codex again from the beginning.",485 );486 }487488 return payload;489}