#!/usr/bin/env node // wasm-import-witness: the PHANTOM-IMPORT witness (ticket #4). // // wasm-ld links user binaries with --allow-undefined, so an undefined // symbol silently becomes an env.* function import that traps at first // call — which means every autoconf AC_CHECK_FUNCS link-probe answers // "yes" and packages ship binaries with phantom imports (bash shipped // importing env.tputs/tgetent/... because configure's termcap search // "passed" with no library). This tool reads the FINAL image's import // section and reports every import outside the sanctioned surface, so // the link driver can refuse loudly at the link, not at the first call. // // Sanctioned surface (closed-world static image): // linux.* (function) — the host syscall ABI (syscall/now_ns/dmesg) // hwjs_suspend.* (function) — the coro-park boundary // env.user_memory (memory) — the host-owned process memory // env.memory (memory) — legacy pre-user_memory images // // Modes: // --mode=static (default) exactly the surface above. // --mode=dynamic-main additionally allows env.__indirect_function_table // (table) — the --import-table import of a dlopen-capable // dynamic MAIN (HWJS-5 STEP 2) — plus EXACTLY ONE env // function: env.__hwjs_xmod_dispatch_check, the F152 // sig-compatible open-world dispatch consult the main-arm // pass emits since superproject issue #52 (a .so hands its // callback fp to a main-side libc API and the MAIN's // dispatch holds a .so-side target; worker.mjs binds the // consult for the MAIN instantiation too). ALL OTHER env // FUNCTION imports stay phantoms: the host instantiates a // main image against exactly the kernel ABI (worker.mjs // filterImports), so an env fn import on a MAIN traps the // instantiate in a booted machine — which is precisely how // the 2026-07-05 glibc pool restructure shipped bash//init // importing env.__nscd_* and the deployed glibc machine's // console failed to load (superproject issue #27): the old // blanket "dynamic" mode sanctioned every env fn import at // this vantage. Never again: a main's env fn import is // refused AT THE LINK (the ONE consult op excepted, by // name — a grown allowlist entry, not a blanket allow). // --mode=dynamic the open-world dlopen SIDE-MODULE surface (HWJS-5/ // ADR-0004): env.__indirect_function_table (table) and env // function imports — a dlopen'd .so imports its libc/ // runtime refs from env by design (the host binds them to // the main image's exports; unresolved ones hit the E8 // registry refuse at load, which is the loud path for that // world). Only an actual -shared link may claim this mode. // // --allow-env=a,b,c extra sanctioned env FUNCTION import names // --needed-so= a NEEDED package library whose exports this main may bind // (repeatable; rides --libc-import, same derivation discipline) // (explicit escape hatch; the caller logs it). // --allow-undefined-from=FILE // names listed in FILE (one per line) are sanctioned // as env function imports. Used by the conftest fast // path: FILE is the libc pool's defined-symbol list, // so a probe referencing a REAL libc symbol links // (the real link resolves it from the pool) while a // nonexistent function fails — configure answers // honestly in fast mode too. REPEATABLE (issue // linux-in-the-browser#63): the conftest fast path // passes one extra FILE per static archive on the // probe's link line (the archive's externally-defined // symbol list), so archive link-probes are attested // without dragging the archive through the link. // // Output: one line per violation, `module.name (kind)`; exit 0 = clean, // exit 1 = violations, exit 2 = usage/parse failure (the caller must treat // 2 as a refusal too — an unparseable image cannot be attested). import fs from 'node:fs'; const args = process.argv.slice(2); let image = null; let mode = 'static'; let allowEnv = new Set(); let rtImport = false; /* PLAN-HWJS-RT-PSABI R4 (--rt-import, dynamic-main only): the runtime-op * surface a main built with WASMLD_RT_IMPORT=1 may import — the loader * binds each from the kernel-provided libhwjs-rt (module #0). EXACTLY the * psABI callable surface (Documentation/wasm/hwjs-rt-abi.rst §11 in the * linux tree); grown by name, never a blanket allow (the issue-#27 * lesson). Adding a name here is a version event (spec §9.4). */ const RT_IMPORT_SURFACE = new Set([ /* frame arena (§1) */ '__hwjs_frame_alloc', '__hwjs_frame_free', '__hwjs_frame_publish', /* parked slot (§3) */ '__hwjs_set_parked', '__hwjs_clear_parked', '__hwjs_take_parked', '__hwjs_noreturn_completed', /* park surface (§4/§5) */ '__hwjs_user_park', '__hwjs_park_take_result', '__hwjs_slice_enter', '__hwjs_slice_exit', '__hwjs_fork_barrier_ok', /* transfer + drive (§6) */ '__hwjs_xfer_descend', '__hwjs_xfer_park', '__hwjs_xfer_return', '__hwjs_xfer_take', '__hwjs_drive', '__hwjs_drive_resume', '__hwjs_drive_done_status', /* SjLj markers (§7) */ 'hwjs_sj_record', 'hwjs_lj_arm_from', 'hwjs_lj_match', 'hwjs_lj_selector', 'hwjs_lj_value', 'hwjs_lj_clear', 'hwjs_lj_unwound', 'hwjs_trap', /* DS regions (§8) */ '__hwjs_ds_region_abi_v2', '__hwjs_region_alloc', '__hwjs_region_save', '__hwjs_region_restore', '__hwjs_freea', /* registration (§10) */ '__hwjs_register_suspends', /* progress/watchdog (§6.5) */ '__hwjs_progress_note', '__hwjs_drive_watch', '__hwjs_progress_violation', '__hwjs_set_stall_bound', /* status channel (§11a; STATUS-CHANNEL-CONTRACT §10 op roster — the * shipped runtime exports these and the EH pre-lowering emits calls to * the raise/clear/violation subset in every check-emitting module, so a * dynamic C++ main imports them from module #0 exactly like the frame * ops. Grown 2026-07-21 for the C++ coverage matrix (litb#104): before * this, a WASMLD_LIBC_IMPORT C++ link refused on phantom * env.__hwjs_status_* imports. */ '__hwjs_status_park_exit', '__hwjs_status_baton', '__hwjs_status_clear', '__hwjs_status_adopt_top', '__hwjs_drive_adopt_parked', '__hwjs_status_raise_unwind', '__hwjs_status_raise_longjmp', '__hwjs_status_sj_register', '__hwjs_status_violation', '__hwjs_status_longjmp_lost', /* §4 terminus hook (grown 2026-07-21, the uncaught-exit-0 fix): the EH * pre-lowering's process-exit-sink checks call it, so a check-emitting * dynamic C main (bash — check-emitting for Track M parity with libc.so, * which carries no libc++abi) imports the RT's weak fallback (a loud * violation) from module #0; a C++ main carries libc++abi's strong * definition in-image and never imports it. */ '__hwjs_uncaught_unwinding', ]); /* RT-owned DATA a dynamic main may reference (GOT.mem globals — the * loader mints module-#0-placement-rebased addresses; worker.mjs GOT.mem * RT fallback). Today exactly the status-channel block: every module in a * process addresses the SAME block (STATUS-CHANNEL-CONTRACT §1). Grown by * name, never a blanket allow. */ const RT_IMPORT_DATA_SURFACE = new Set([ '__hwjs_status_channel', ]); /* (PLAN-DYNAMIC-LIBC): --libc-import (dynamic-main only, the WASMLD_LIBC_IMPORT=1 arm). * Sanctions, ALL DERIVED from the built libc.so named by --libc-so= (never hand-maintained; * §3.6 derivation discipline — a rebuilt libc.so re-derives the lists on the next link): * env. (function import) iff libc.so exports a FUNCTION ; * GOT.func. (global import) iff libc.so exports a FUNCTION (address-taken-only); * GOT.mem. (global import) iff libc.so exports a GLOBAL (PIC data exports). * This is a SOUNDNESS gate, not hygiene: under --unresolved-symbols=import-dynamic ANY undefined * datum — a misspelled one included — links rc=0 as a plausible GOT.mem import; the by-name * cross-check is what turns RED-1's silent NULL bind into a BUILD failure (S5 judge change (i)). * WEAK-UNDEFINED-DATA DISPOSITION (S5 judge change (vi)): weak extern data gets NO exemption — a * weak ref that materialized as a GOT.mem import must resolve from libc.so's data exports exactly * like a strong one (ELF's benign-null-weak idiom would be a silent NULL deref here; a genuinely * optional datum must be spelled main-side, not left dangling). * --suspenders-partition= (the PUBLISHED verdict file) additionally requires every * libc.so-sanctioned env fn import to be LISTED (any verdict): an import absent from the partition * is a link fatal, never a silently-unseeded orphan (F151 both-directions trap); verdict `refused` * is refused outright — a refused libc.so frame is native and undivertable (cross-module-unwind backstop, §3.3). */ let libcImport = false; let libcSoPath = null; const neededSoPaths = []; let suspFile = null; const allowFiles = []; for (const a of args) { if (a.startsWith('--mode=')) mode = a.slice(7); else if (a.startsWith('--allow-env=')) { for (const n of a.slice(12).split(',')) if (n) allowEnv.add(n); } else if (a.startsWith('--allow-undefined-from=')) allowFiles.push(a.slice(23)); else if (a === '--rt-import') rtImport = true; else if (a === '--libc-import') libcImport = true; else if (a.startsWith('--libc-so=')) libcSoPath = a.slice(10); else if (a.startsWith('--needed-so=')) neededSoPaths.push(a.slice(12)); else if (a.startsWith('--suspenders-partition=')) suspFile = a.slice(23); else if (!a.startsWith('--')) image = a; else { console.error(`wasm-import-witness: unknown option '${a}'`); process.exit(2); } } if (rtImport && mode !== 'dynamic-main') { console.error('wasm-import-witness: --rt-import is a dynamic-main mode grown allowlist (a static image carries the archive packaging; a -shared module is open-world already)'); process.exit(2); } if (libcImport && mode !== 'dynamic-main') { console.error('wasm-import-witness: --libc-import is a dynamic-main mode grown allowlist (a static image carries the pool packaging; a -shared module is open-world already) — PLAN-DYNAMIC-LIBC'); process.exit(2); } if (libcImport && !libcSoPath) { console.error('wasm-import-witness: --libc-import requires --libc-so= (the sanction lists are DERIVED from the artifact, never hand-maintained)'); process.exit(2); } let libcFnExports = new Set(); let libcDataExports = new Set(); if (libcImport) { let libcMod; try { libcMod = new WebAssembly.Module(fs.readFileSync(libcSoPath)); } catch (e) { console.error(`wasm-import-witness: cannot compile libc.so '${libcSoPath}': ${e.message}`); process.exit(2); } for (const ex of WebAssembly.Module.exports(libcMod)) { if (ex.kind === 'function') libcFnExports.add(ex.name); else if (ex.kind === 'global') libcDataExports.add(ex.name); } if (libcFnExports.size === 0) { console.error(`wasm-import-witness: '${libcSoPath}' exports ZERO functions — wrong artifact for --libc-so?`); process.exit(2); } /* OWNERSHIP PIN (2026-07-17, the region-using-mains-refuse finding): the DS-region family is * RT-OWNED — defined by the kernel-provided libhwjs-rt (module #0), psABI hwjs-rt-abi.rst §8, * callable per §11 (the RT_IMPORT_SURFACE entries above). A libc.so claiming any of these names * in its EXPORT surface would silently re-own them right here: the derived lists would sanction * the main's env./GOT.func sentinel+region imports as libc-bound, and worker.mjs would then bind * them to module #1 instead of module #0 — the issue-#27 blanket-sanction class wearing a * plausible per-name derivation. Refused at derivation time (exit 2 — an unattestable/wrong * --libc-so artifact, same class as the zero-fn-exports refusal above), never per-import: the * artifact is wrong regardless of what the judged image happens to import. Deliberately scoped * to the §8 family — other RT-surface names can legitimately collide (libc.so exports its OWN * per-module __hwjs_register_suspends registrar, for example). */ const DS_REGION_FAMILY = ['__hwjs_ds_region_abi_v2', '__hwjs_region_alloc', '__hwjs_region_save', '__hwjs_region_restore', '__hwjs_freea']; for (const n of DS_REGION_FAMILY) { if (libcFnExports.has(n) || libcDataExports.has(n)) { console.error(`wasm-import-witness: '${libcSoPath}' EXPORTS ${n} — the DS-region family is RT-owned (kernel libhwjs-rt, module #0; psABI hwjs-rt-abi.rst §8/§11) and must never enter libc.so's derived sanction lists (a consumer main's binding would silently land on module #1 at load). Wrong/doctored --libc-so artifact; refusing to attest.`); process.exit(2); } } } let partitionVerdicts = null; /* (BRINTOS-886) The NON-LIBC half of the needed-graph: every --needed-so= library's * export surface joins the sanctioned set, derived from the artifact exactly as libc.so's * is. Two rules differ, and both are deliberate: * - NO partition requirement. A package library publishes no suspender partition (per- * library partitions are a later milestone), so demanding one would refuse every * package-library import on a technicality rather than on a fact. * - A name BOTH libc.so and a package library export is reported, not refused: the * loader binds in needed-list order with libc.so first, so libc wins deterministically * and the NOTE says so by name. Refusing would outlaw the ordinary case of a library * shipping its own copy of a small libc-ish helper. */ let neededFnExports = new Set(); let neededDataExports = new Set(); if (neededSoPaths.length) { if (!libcImport) { console.error('wasm-import-witness: --needed-so only rides --libc-import (a needed-list is appended by the dynamic-main consumer arm alone)'); process.exit(2); } for (const p of neededSoPaths) { let m; try { m = new WebAssembly.Module(fs.readFileSync(p)); } catch (e) { console.error(`wasm-import-witness: cannot compile needed library '${p}': ${e.message}`); process.exit(2); } const fns = new Set(), data = new Set(); for (const ex of WebAssembly.Module.exports(m)) { if (ex.kind === 'function') fns.add(ex.name); else if (ex.kind === 'global') data.add(ex.name); } if (fns.size === 0 && data.size === 0) { console.error(`wasm-import-witness: needed library '${p}' exports NOTHING — a library a main records a needed-entry for and can bind nothing from is a wrong artifact, not an empty one.`); process.exit(2); } /* The same OWNERSHIP PIN libc.so takes: the DS-region family is RT-owned (module #0). * A package library claiming one of these names would have the main's binding land on * it instead of the runtime — the issue-#27 class, one module further out. */ for (const n of ['__hwjs_ds_region_abi_v2', '__hwjs_region_alloc', '__hwjs_region_save', '__hwjs_region_restore', '__hwjs_freea']) { if (fns.has(n) || data.has(n)) { console.error(`wasm-import-witness: needed library '${p}' EXPORTS ${n} — the DS-region family is RT-owned (kernel libhwjs-rt, module #0; psABI hwjs-rt-abi.rst §8/§11) and must never enter a derived sanction list. Wrong/doctored artifact; refusing to attest.`); process.exit(2); } } for (const n of fns) { if (libcFnExports.has(n)) console.error(`wasm-import-witness: NOTE — '${p}' and libc.so both export function ${n}; the loader walks the needed-list in order with libc.so first, so a main's env.${n} binds to LIBC.SO. Named here rather than refused.`); neededFnExports.add(n); } for (const n of data) { if (libcDataExports.has(n)) console.error(`wasm-import-witness: NOTE — '${p}' and libc.so both export global ${n}; libc.so wins the bind (needed-list order). Named here rather than refused.`); neededDataExports.add(n); } } } if (suspFile) { if (!libcImport) { console.error('wasm-import-witness: --suspenders-partition only rides --libc-import (the partition gates the libc-bound env fn surface)'); process.exit(2); } partitionVerdicts = new Map(); try { for (const line of fs.readFileSync(suspFile, 'utf8').split('\n')) { const t = line.trim(); if (!t) continue; const i = t.indexOf(' '); if (i <= 0) { console.error(`wasm-import-witness: malformed partition line '${t}' in ${suspFile}`); process.exit(2); } /* Track M (PLAN-CXX-STATUS-CHANNEL §7): a status-channel producer suffixes every verdict * token with the +nothrow/+throws lane. Strip it — this vantage judges the base verdict * only (same rule as wasmld's partition cross-verify). Without the strip, a laned token * ('refused+throws') fails the exact-match 'refused' test below and the refused-import * check silently never fires — under the one partition shape the status channel makes * MANDATORY (audit finding; the check neither refused nor errored, it no-opped). */ partitionVerdicts.set(t.slice(i + 1), t.slice(0, i).split('+')[0]); } } catch (e) { console.error(`wasm-import-witness: cannot read --suspenders-partition file '${suspFile}': ${e.message}`); process.exit(2); } } if (!image || (mode !== 'static' && mode !== 'dynamic' && mode !== 'dynamic-main')) { console.error('usage: wasm-import-witness.mjs image.wasm [--mode=static|dynamic-main|dynamic] [--allow-env=a,b] [--allow-undefined-from=FILE]...'); process.exit(2); } for (const allowFile of allowFiles) { try { for (const line of fs.readFileSync(allowFile, 'utf8').split('\n')) { const n = line.trim(); if (n) allowEnv.add(n); } } catch (e) { console.error(`wasm-import-witness: cannot read --allow-undefined-from file '${allowFile}': ${e.message}`); process.exit(2); } } let mod; try { mod = new WebAssembly.Module(fs.readFileSync(image)); } catch (e) { console.error(`wasm-import-witness: cannot compile '${image}': ${e.message}`); process.exit(2); } const violations = []; for (const imp of WebAssembly.Module.imports(mod)) { const { module: m, name, kind } = imp; if (m === 'linux' && kind === 'function') continue; if (m === 'hwjs_suspend' && kind === 'function') continue; if (m === 'env' && kind === 'memory' && (name === 'user_memory' || name === 'memory')) continue; if (m === 'env' && kind === 'function' && allowEnv.has(name)) continue; // PLAN-THREADS-SINGLE-MEMORY §4 step 2 (ADDRESS-GLOBAL): a LANE-STAMPED // image (hwjs.abi flags bit5) addresses the status channel through the // per-instance wasm global env.__hwjs_status_channel_addr — a genuinely // host-bound structural import in EVERY mode (dynLoader binds it for // .so's; worker.mjs binds &status_channel[lane] for mains and refuses a // 0/absent bind loudly). Sanctioned BY EXACT NAME+KIND only; any other // env global still refuses. if (m === 'env' && kind === 'global' && name === '__hwjs_status_channel_addr') continue; // F86 safepoint budget, side-module leg (PLAN-THREADS §4 step 8): in a // -shared module the pass's weak per-instance wasm-global budget cannot // survive as a definition — it surfaces as an env global import the // loader binds per instance (fresh mutable 0, fail-safe park-until- // refill) and the driver refills alongside the main's accessor // (worker.mjs setModuleBudgets). Sanctioned BY EXACT NAME+KIND, SIDE // MODULES ONLY — a main (static or dynamic) holds the weak DEFINITION, // so a budget import there is a foreign/broken artifact and refuses. if (m === 'env' && kind === 'global' && name === '__hwjs_budget' && mode === 'dynamic') continue; if (mode === 'dynamic' || mode === 'dynamic-main') { if (m === 'env' && kind === 'table' && name === '__indirect_function_table') continue; // Issue #52: the F152 open-world dispatch consult is now emitted for // dynamic MAINS too (the .so-callback-into-main-libc shape); the host // binds it at the MAIN instantiation. // F163 (glibc-arm Finding B) adds the KERNEL-REGISTRY fpcast protocol ops: // a dynamic MAIN no longer self-satisfies the F117 registrar / consult // lookups with weak no-op stubs (the stub swallowed every main-owned // record — a libc.so consult could never resolve a main-side qsort // comparator), so they are env imports the host pre-binds to the LIVE // kernel registry (vmlinux exports; worker.mjs main pre-bind). These // MUST be sanctioned BEFORE the libc.so-derived branch below: libc.so // incidentally EXPORTS its own weak stubs under the same names, and a // libc-bound sanction would demand partition verdicts for — and at load // bind the main to — module #1's no-op stubs (the record-loss bug // again). Exactly these names — every other env fn import on a main // stays a refused phantom. if (m === 'env' && kind === 'function' && (name === '__hwjs_xmod_dispatch_check' || name === '__hwjs_register_fpcast_adapter' || name === '__hwjs_fpcast_adapter_lookup' || name === '__hwjs_fpcast_target_sig_lookup')) continue; /* R4 runtime-import mains: exactly the kernel-provided runtime's * callable surface, when the link declared --rt-import. */ if (rtImport && m === 'env' && kind === 'function' && RT_IMPORT_SURFACE.has(name)) continue; /* ...and the ADDRESS-TAKEN form of that same surface. Taking a runtime * op's address (rather than calling it) lowers to a GOT.func GLOBAL, not * an env function import, so the exemption above never fired and the * libc.so GOT.func check below refused it as a phantom — the runtime is * module #0, so a §11 name is legitimately absent from libc.so's export * list. The loader mints the identity from the runtime provider * (worker.mjs GOT.func resolution falls back to the rt handle's dlsym). * Exactly the §11 surface: any other GOT.func global still refuses. */ if (rtImport && m === 'GOT.func' && kind === 'global' && RT_IMPORT_SURFACE.has(name)) continue; /* ...and the §11a RT-owned DATA surface (GOT.mem): the status-channel * block lives in module #0's data, so a check-emitting dynamic main's * status loads reference it as GOT.mem.__hwjs_status_channel — never a * libc.so datum. The loader mints the rebased address from the rt * placement; exactly the named surface, all else still refuses. */ if (rtImport && m === 'GOT.mem' && kind === 'global' && RT_IMPORT_DATA_SURFACE.has(name)) continue; /* libc.so-import mains (--libc-import): by-name sanction from the DERIVED lists. */ if (libcImport && m === 'env' && kind === 'function' && libcFnExports.has(name)) { if (partitionVerdicts) { const v = partitionVerdicts.get(name); if (v === undefined) { violations.push(`env.${name} (function) — exported by libc.so but ABSENT from the published suspender partition (a mis-published/stale partition would silently mis-seed this import's suspend class; PLAN-DYNAMIC-LIBC §3.2)`); continue; } if (v === 'refused') { violations.push(`env.${name} (function) — libc.so publishes verdict REFUSED for it (native, undivertable: a cross-module 2b transfer through it is silently lost; the cross-module-unwind backstop refuses the binding — PLAN-DYNAMIC-LIBC §3.3)`); continue; } } continue; } /* (BRINTOS-886) ...and from the NEEDED package libraries, by the same by-name * derivation. Placed BEFORE the libc GOT catch-all below, which refuses every * GOT.mem/GOT.func not in libc.so's own lists. */ if (m === 'env' && kind === 'function' && neededFnExports.has(name)) continue; if (m === 'GOT.func' && kind === 'global' && neededFnExports.has(name)) continue; if (m === 'GOT.mem' && kind === 'global' && neededDataExports.has(name)) continue; if (libcImport && kind === 'global') { if (m === 'GOT.mem' && libcDataExports.has(name)) continue; if (m === 'GOT.func' && libcFnExports.has(name)) continue; if (m === 'GOT.mem' || m === 'GOT.func') { violations.push(`${m}.${name} (global) — NOT in libc.so's derived ${m === 'GOT.mem' ? 'data' : 'function'}-export list: an undefined ${m === 'GOT.mem' ? 'datum' : 'address-taken function'} under import-dynamic links rc=0 and would bind NULL/dangling at load (RED-1 class; weak refs get NO exemption — PLAN-DYNAMIC-LIBC §3.4 (i)/(vi))`); continue; } } } if (mode === 'dynamic') { if (m === 'env' && kind === 'function') continue; // PIC dylib STRUCTURAL global imports (every --experimental-pic -shared // module carries them; the host loader binds each at dlopen): // env.__memory_base / env.__table_base — the module's data/table slide; // env.__stack_pointer — the process shadow-stack pointer Global the // dynamic MAIN now exports (wasmld --export=__stack_pointer); // GOT.func.* / GOT.mem.* — runtime-resolved cross-module addresses // (dlsym-identity for functions, data addresses for globals). if (m === 'env' && kind === 'global' && (name === '__memory_base' || name === '__table_base' || name === '__stack_pointer')) continue; if ((m === 'GOT.func' || m === 'GOT.mem') && kind === 'global') continue; } violations.push(`${m}.${name} (${kind})`); } if (violations.length) { for (const v of violations) console.log(v); process.exit(1); } process.exit(0);