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 }