tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

bundle.ts (9566B)


      1 import { Graph, type NodeId } from './graph.js';
      2 export type BundleHash = string & { readonly __bundleHash: unique symbol };
      3 export interface ExportInfo { name: string; root: number; kind: 'term'; abi: 'arboricx.abi.tree.v1'; }
      4 type RecordNode = { kind: 'leaf' } | { kind: 'stem'; child: number } | { kind: 'fork'; left: number; right: number };
      5 export interface Bundle {
      6   exports: ExportInfo[]; roots: number[]; nodes: RecordNode[]; metadata: Record<string, string>;
      7 }
      8 export const BUNDLE_LIMIT = 16_777_216;
      9 // Separate bounded profile for the explicitly selected compiler, not packages.
     10 export const COMPILER_BUNDLE_LIMIT = 2_097_152;
     11 const utf8 = new TextDecoder('utf-8', { fatal: true });
     12 class Reader {
     13   private at = 0;
     14   private readonly view: DataView;
     15   constructor(readonly bytes: Uint8Array) { this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); }
     16   take(count: number): Uint8Array {
     17     if (!Number.isSafeInteger(count) || count < 0 || count > this.bytes.length - this.at) throw new Error('Truncated bundle field');
     18     const value = this.bytes.subarray(this.at, this.at + count); this.at += count; return value;
     19   }
     20   u8(): number { return this.take(1)[0]!; }
     21   u16(): number { const at = this.at; this.take(2); return this.view.getUint16(at); }
     22   u32(): number { const at = this.at; this.take(4); return this.view.getUint32(at); }
     23   u64(): number {
     24     const at = this.at; this.take(8); const n = this.view.getBigUint64(at);
     25     if (n > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('Oversized u64');
     26     return Number(n);
     27   }
     28   string(limit = 4096): string {
     29     const length = this.u32(); if (length > limit) throw new Error('Bundle string limit exceeded');
     30     return utf8.decode(this.take(length));
     31   }
     32   count(limit = 4096): number {
     33     const n = this.u32(); if (n > limit) throw new Error('Bundle count limit exceeded'); return n;
     34   }
     35   end(): void { if (this.at !== this.bytes.length) throw new Error('Trailing section bytes'); }
     36 }
     37 function requireValue<T>(actual: T, expected: T, label: string): void {
     38   if (actual !== expected) throw new Error(`Unsupported/invalid ${label}`);
     39 }
     40 export function bundleHash(value: unknown): BundleHash {
     41   if (typeof value !== 'string' || !/^[0-9a-f]{64}$/u.test(value)) throw new Error('Expected 64 lowercase hexadecimal bundle hash characters');
     42   return value as BundleHash;
     43 }
     44 export async function sha256(bytes: Uint8Array): Promise<BundleHash> {
     45   // Copy into an owned ArrayBuffer: no SharedArrayBuffer boundary or mutation
     46   // can change the bytes being checked while validation/loading proceeds.
     47   const result = await crypto.subtle.digest('SHA-256', new Uint8Array(bytes));
     48   return bundleHash(Array.from(new Uint8Array(result), b => b.toString(16).padStart(2, '0')).join(''));
     49 }
     50 export async function fetchBytes(url: URL, limit = BUNDLE_LIMIT): Promise<Uint8Array> {
     51   const response = await fetch(url, { redirect: 'error', credentials: 'omit' });
     52   if (!response.ok) throw new Error(`Bundle fetch: HTTP ${response.status}`);
     53   const length = response.headers.get('content-length');
     54   if (length && (!/^\d+$/u.test(length) || Number(length) > limit)) {
     55     await response.body?.cancel(); throw new Error('Bundle download limit exceeded');
     56   }
     57   if (!response.body) throw new Error('Missing response body');
     58   const reader = response.body.getReader(), chunks: Uint8Array[] = []; let size = 0;
     59   try {
     60     for (;;) {
     61       const item = await reader.read(); if (item.done) break;
     62       size += item.value.length;
     63       if (size > limit) throw new Error('Bundle download limit exceeded');
     64       chunks.push(item.value);
     65     }
     66   } catch (error) { await reader.cancel().catch(() => {}); throw error; }
     67   finally { reader.releaseLock(); }
     68   const bytes = new Uint8Array(size); let offset = 0;
     69   for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
     70   return bytes;
     71 }
     72 export async function fetchBundle(url: URL, expected: BundleHash, byteLimit = BUNDLE_LIMIT): Promise<Bundle> {
     73   const bytes = await fetchBytes(url, byteLimit);
     74   if (await sha256(bytes) !== expected) throw new Error('Bundle SHA-256 mismatch');
     75   return decodeBundle(bytes, byteLimit);
     76 }
     77 // Validate completely before touching the graph heap. All loops count physical
     78 // records/bytes, never unfolded tree occurrences. No source/JS/WASM sections run.
     79 export function decodeBundle(bytes: Uint8Array, byteLimit = BUNDLE_LIMIT): Bundle {
     80   if (bytes.length > byteLimit) throw new Error('Bundle byte limit exceeded');
     81   const r = new Reader(bytes);
     82   requireValue(utf8.decode(r.take(8)), 'ARBORICX', 'bundle magic');
     83   requireValue(r.u16(), 1, 'container major'); requireValue(r.u16(), 0, 'container minor');
     84   const count = r.count(32);
     85   requireValue(r.u64(), 0, 'header flags'); requireValue(r.u64(), 32, 'directory offset');
     86   const sections = new Map<number, Uint8Array>(), ranges: Array<[number, number]> = [];
     87   for (let i = 0; i < count; i++) {
     88     const type = r.u32(), version = r.u16(), flags = r.u16(), compression = r.u16();
     89     requireValue(r.u16(), 0, 'section reserved');
     90     const start = r.u64(), size = r.u64(); requireValue(r.u32(), 0, 'section padding');
     91     if (start < 32 + count * 32 || start > bytes.length || size > bytes.length - start) throw new Error('Section out of bounds');
     92     if (sections.has(type)) throw new Error('Duplicate section');
     93     if (flags & ~1) throw new Error('Unknown section flags');
     94     if (type === 1 || type === 2) { requireValue(version, 1, 'section version'); requireValue(compression, 0, 'compression'); }
     95     else if (flags & 1) throw new Error('Unknown critical section');
     96     ranges.push([start, start + size]); sections.set(type, bytes.subarray(start, start + size));
     97   }
     98   ranges.sort((a, b) => a[0] - b[0]); let end = 32 + count * 32;
     99   for (const [start, stop] of ranges) { if (start !== end) throw new Error('Overlapping/gapped sections'); end = stop; }
    100   if (end !== bytes.length) throw new Error('Trailing container bytes');
    101   const manifest = sections.get(1), nodeBytes = sections.get(2);
    102   if (!manifest || !nodeBytes) throw new Error('Missing required sections');
    103   const m = new Reader(manifest);
    104   requireValue(utf8.decode(m.take(8)), 'ARBMNFST', 'manifest magic');
    105   requireValue(m.u16(), 1, 'manifest major'); requireValue(m.u16(), 1, 'manifest minor');
    106   for (const expected of ['arboricx.bundle.manifest.v1', 'tree-calculus-executable-object',
    107     'tree-calculus.v1', 'indexed', 'arboricx.indexed.node.v1', 'arboricx.indexed.payload.v1',
    108     'tree-calculus.v1', 'normal-order', 'arboricx.abi.tree.v1']) requireValue(m.string(), expected, 'manifest semantics');
    109   requireValue(m.u32(), 0, 'runtime capabilities'); requireValue(m.u8(), 0, 'complete closure');
    110   const roots: number[] = [];
    111   for (let i = m.count(); i > 0; i--) { roots.push(m.u32()); m.string(); }
    112   const exports: ExportInfo[] = [], names = new Set<string>();
    113   for (let i = m.count(); i > 0; i--) {
    114     const name = m.string(512), root = m.u32();
    115     requireValue(m.string(), 'term', 'export kind'); requireValue(m.string(), 'arboricx.abi.tree.v1', 'export ABI');
    116     if (!name || names.has(name)) throw new Error('Empty/duplicate export name');
    117     names.add(name); exports.push({ name, root, kind: 'term', abi: 'arboricx.abi.tree.v1' });
    118   }
    119   if (!roots.length || !exports.length) throw new Error('Bundle requires roots/exports');
    120   const metadata: Record<string, string> = Object.create(null) as Record<string, string>;
    121   const tags: Record<number, string> = { 1: 'package', 2: 'version', 3: 'description', 4: 'license', 5: 'createdBy' };
    122   for (let i = m.count(); i > 0; i--) {
    123     const tag = m.u16(), length = m.u32(); if (length > 16_384) throw new Error('Metadata limit exceeded');
    124     const value = m.take(length), key = tags[tag];
    125     if (key) { if (Object.hasOwn(metadata, key)) throw new Error('Duplicate metadata'); metadata[key] = utf8.decode(value); }
    126   }
    127   for (let i = m.count(); i > 0; i--) { m.u16(); m.take(m.u32()); }
    128   m.end();
    129   const n = new Reader(nodeBytes), total = n.u64();
    130   if (!total || total > 100_000) throw new Error('Bundle node count limit exceeded');
    131   const nodes: RecordNode[] = [], keys = new Set<string>();
    132   for (let i = 0; i < total; i++) {
    133     const p = new Reader(n.take(n.u32())), tag = p.u8(); let node: RecordNode, key: string;
    134     const child = (): number => { const id = p.u32(); if (id >= i) throw new Error('Non-backward child reference'); return id; };
    135     if (tag === 0) { node = { kind: 'leaf' }; key = '0'; }
    136     else if (tag === 1) { const id = child(); node = { kind: 'stem', child: id }; key = `1,${id}`; }
    137     else if (tag === 2) { const left = child(), right = child(); node = { kind: 'fork', left, right }; key = `2,${left},${right}`; }
    138     else throw new Error('Unknown node tag');
    139     p.end(); if (keys.has(key)) throw new Error('Duplicate indexed node');
    140     keys.add(key); nodes.push(node);
    141   }
    142   n.end();
    143   for (const root of [...roots, ...exports.map(e => e.root)]) if (root >= total) throw new Error('Root index out of bounds');
    144   return { roots, exports, nodes, metadata };
    145 }
    146 export function loadBundle(graph: Graph, bundle: Bundle): Map<string, NodeId> {
    147   const handles: NodeId[] = [];
    148   for (const node of bundle.nodes) {
    149     graph.tick();
    150     switch (node.kind) {
    151       case 'leaf': handles.push(graph.leaf); break;
    152       case 'stem': handles.push(graph.stem(handles[node.child]!)); break;
    153       case 'fork': handles.push(graph.fork(handles[node.left]!, handles[node.right]!)); break;
    154     }
    155   }
    156   return new Map(bundle.exports.map(e => [e.name, handles[e.root]!]));
    157 }