- wai/wai-vector/wasm: the sink's exports (wv_alloc, wv_free, wv_open, wv_close, wv_render, wv_out_ptr, wv_out_len, wv_sha256, wv_cost, wv_transcript, wv_projection), no_std with its own allocator, built for wasm32v1-none so the module is WebAssembly 1.0 only; check-mvp.mjs refuses any post-1.0 feature, and fails on the wasm32-unknown-unknown build. - build-pkg.sh: the package a page serves. - browser/: a harness that renders every listed frame of every vector in Chromium, WebKit and Firefox, hashes it in the page twice, re-hashes sampled frames in Node, recomputes the arithmetic vectors and authoring forms, and compares each a11y payload's transcript and projections with its text record. A missing engine fails the run. - wai-vector.yml: the browser job, in a Playwright container pinned by digest, on the linux runner. Co-Authored-By: Claude Opus 5.5 <[email protected]>
385 lines
18 KiB
JavaScript
385 lines
18 KiB
JavaScript
// Read a WebAssembly module and refuse it unless it is WebAssembly 1.0 and nothing
|
|
// later: every section, type, import, export, segment and instruction is read, and
|
|
// anything a later proposal added is a finding. No dependency: the binary format is
|
|
// decoded here.
|
|
//
|
|
// node check-mvp.mjs <module.wasm> [--package] [--functions <name,name,…>]
|
|
//
|
|
// Always checked:
|
|
// - sections: only the eleven of 1.0 and custom sections, in order (no DataCount
|
|
// section, no tag section);
|
|
// - types: function types over i32, i64, f32 and f64, with at most one result;
|
|
// - one memory and one table at most, neither shared nor 64-bit; a table of
|
|
// function references;
|
|
// - no mutable global imported or exported;
|
|
// - element and data segments of the 1.0 kind only (active, memory or table 0);
|
|
// - constant expressions of one constant or one global.get;
|
|
// - every instruction of every function is one of 1.0's, with 1.0's immediates:
|
|
// no SIMD, no atomics, no bulk memory, no sign-extension operators, no
|
|
// saturating conversions, no reference types, no multi-value blocks, no tail
|
|
// calls, no exceptions, and a call_indirect that names table 0 in one byte;
|
|
// - a `target_features` section, if present, declares none of them in use.
|
|
//
|
|
// With --package, the rules of a module a page instantiates with no glue script:
|
|
// - no imports;
|
|
// - no exported function takes or returns i64, which a caller could pass only
|
|
// through the engine's later BigInt interface;
|
|
// - no floating-point type, constant or instruction anywhere.
|
|
//
|
|
// With --functions, the exported functions are exactly the names given.
|
|
//
|
|
// Prints what it found. Exit status 0: the module passes; 1: it does not; 2: usage,
|
|
// or a module this reader cannot decode.
|
|
import { createHash } from 'node:crypto';
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
let file = null, pkg = false, functions = null, usage = false;
|
|
for (let a = process.argv.slice(2), i = 0; i < a.length; i++) {
|
|
if (a[i] === '--package') pkg = true;
|
|
else if (a[i] === '--functions' && i + 1 < a.length) functions = a[++i].split(',').filter(Boolean);
|
|
else if (!a[i].startsWith('--') && file === null) file = a[i];
|
|
else usage = true;
|
|
}
|
|
if (usage || file === null) {
|
|
console.error('usage: node check-mvp.mjs <module.wasm> [--package] [--functions <name,name,…>]');
|
|
process.exit(2);
|
|
}
|
|
|
|
const I32 = 0x7f, I64 = 0x7e, F32 = 0x7d, F64 = 0x7c;
|
|
const NUM = new Set([I32, I64, F32, F64]);
|
|
|
|
class Reader {
|
|
constructor(b, at = 0, end = b.length) { this.b = b; this.at = at; this.end = end; }
|
|
eof() { return this.at >= this.end; }
|
|
byte() {
|
|
if (this.at >= this.end) throw new Error(`truncated at byte ${this.at}`);
|
|
return this.b[this.at++];
|
|
}
|
|
// An unsigned LEB128 of at most `max` bytes.
|
|
u(max = 5) {
|
|
let r = 0, s = 1;
|
|
for (let i = 0; i < max; i++) {
|
|
const c = this.byte();
|
|
r += (c & 0x7f) * s;
|
|
s *= 128;
|
|
if (!(c & 0x80)) return r;
|
|
}
|
|
throw new Error(`a LEB128 longer than ${max} bytes before byte ${this.at}`);
|
|
}
|
|
skip(n) {
|
|
if (this.at + n > this.end) throw new Error(`truncated at byte ${this.at}`);
|
|
this.at += n;
|
|
}
|
|
sub(n) {
|
|
const r = new Reader(this.b, this.at, this.at + n);
|
|
this.skip(n);
|
|
return r;
|
|
}
|
|
name() {
|
|
const n = this.u();
|
|
const s = new TextDecoder().decode(this.b.subarray(this.at, this.at + n));
|
|
this.skip(n);
|
|
return s;
|
|
}
|
|
}
|
|
|
|
// What was found, by feature: a count and the first place.
|
|
const found = new Map();
|
|
function finding(feature, where) {
|
|
const f = found.get(feature);
|
|
if (f) f.count += 1;
|
|
else found.set(feature, { count: 1, where });
|
|
}
|
|
|
|
const SECTION = ['custom', 'type', 'import', 'function', 'table', 'memory', 'global', 'export', 'start', 'element', 'code', 'data'];
|
|
const types = []; // { params: [valtype], results: [valtype] }
|
|
const funcTypes = []; // function index -> type index, imports first
|
|
const globalsMutable = []; // global index -> mutable, imports first
|
|
const exports = []; // { name, kind, index }
|
|
const imports = []; // "module.field"
|
|
let tables = 0, memories = 0, floats = 0, bodies = 0, instructions = 0;
|
|
let declared = null; // the target_features section's entries
|
|
|
|
function valtype(t, where) {
|
|
if (NUM.has(t)) {
|
|
if (t === F32 || t === F64) floats += 1;
|
|
return;
|
|
}
|
|
if (t === 0x7b) finding('simd128 (the type v128)', where);
|
|
else if (t === 0x70 || t === 0x6f) finding('reference-types (a reference as a value type)', where);
|
|
else finding(`a value type 0x${t.toString(16)} that 1.0 does not have`, where);
|
|
}
|
|
|
|
function limits(r, what, where) {
|
|
const flag = r.u();
|
|
if (flag & 0x02) finding(`threads (a shared ${what})`, where);
|
|
if (flag & 0x04) finding(`memory64 (a 64-bit ${what})`, where);
|
|
if (flag > 0x07) finding(`${what} limits with flags 0x${flag.toString(16)}`, where);
|
|
r.u(10);
|
|
if (flag & 0x01) r.u(10);
|
|
}
|
|
|
|
// A constant expression: one constant or one global.get, then end.
|
|
function constExpr(r, where) {
|
|
const op = r.byte();
|
|
if (op === 0x41) r.u(5);
|
|
else if (op === 0x42) r.u(10);
|
|
else if (op === 0x43) { r.skip(4); floats += 1; }
|
|
else if (op === 0x44) { r.skip(8); floats += 1; }
|
|
else if (op === 0x23) r.u();
|
|
else {
|
|
finding('a constant expression beyond one constant or global.get (extended-const, or a reference)', where);
|
|
throw new Error('stop');
|
|
}
|
|
if (r.byte() !== 0x0b) {
|
|
finding('a constant expression beyond one constant or global.get (extended-const)', where);
|
|
throw new Error('stop');
|
|
}
|
|
}
|
|
|
|
const FLOAT_OP = (op) => op === 0x2a || op === 0x2b || op === 0x38 || op === 0x39
|
|
|| (op >= 0x5b && op <= 0x66) || (op >= 0x8b && op <= 0xa6)
|
|
|| (op >= 0xa8 && op <= 0xab) || (op >= 0xae && op <= 0xbf);
|
|
|
|
function blockType(r, where) {
|
|
const t = r.byte();
|
|
if (t === 0x40) return;
|
|
if (NUM.has(t)) { valtype(t, where); return; }
|
|
if (t === 0x7b || t === 0x70 || t === 0x6f) { valtype(t, where); return; }
|
|
// Anything else is a type index, as a signed LEB128.
|
|
finding('multivalue (a block whose type is a type index)', where);
|
|
if (t & 0x80) { r.at -= 1; r.u(5); }
|
|
}
|
|
|
|
// One function body's instructions. Returns false when it met an instruction whose
|
|
// immediates this reader does not know, and stopped.
|
|
function body(r, where) {
|
|
while (!r.eof()) {
|
|
const at = r.at;
|
|
const op = r.byte();
|
|
const here = `${where}, byte 0x${at.toString(16)}`;
|
|
instructions += 1;
|
|
if (op === 0x02 || op === 0x03 || op === 0x04) blockType(r, here);
|
|
else if (op === 0x0c || op === 0x0d || op === 0x10 || (op >= 0x20 && op <= 0x24)) r.u();
|
|
else if (op === 0x0e) { for (let n = r.u(); n > 0; n--) r.u(); r.u(); }
|
|
else if (op === 0x11) {
|
|
r.u();
|
|
const t = r.byte();
|
|
if (t !== 0x00) {
|
|
finding('reference-types (a call_indirect that names a table, or encodes table 0 in more than one byte)', here);
|
|
if (t & 0x80) { r.at -= 1; r.u(5); }
|
|
}
|
|
} else if (op >= 0x28 && op <= 0x3e) {
|
|
const align = r.u();
|
|
if (align >= 64) { finding('multi-memory (a load or store that names a memory)', here); r.u(); }
|
|
r.u(10);
|
|
if (FLOAT_OP(op)) floats += 1;
|
|
} else if (op === 0x3f || op === 0x40) {
|
|
const m = r.byte();
|
|
if (m !== 0x00) {
|
|
finding('multi-memory (memory.size or memory.grow that names a memory, or encodes memory 0 in more than one byte)', here);
|
|
if (m & 0x80) { r.at -= 1; r.u(5); }
|
|
}
|
|
} else if (op === 0x41) r.u(5);
|
|
else if (op === 0x42) r.u(10);
|
|
else if (op === 0x43) { r.skip(4); floats += 1; }
|
|
else if (op === 0x44) { r.skip(8); floats += 1; }
|
|
else if (op === 0x00 || op === 0x01 || op === 0x05 || op === 0x0b || op === 0x0f || op === 0x1a || op === 0x1b) { /* no immediate */ }
|
|
else if (op >= 0x45 && op <= 0xbf) { if (FLOAT_OP(op)) floats += 1; }
|
|
else if (op >= 0xc0 && op <= 0xc4) finding('sign-ext (i32.extend8_s and its kin)', here);
|
|
else if (op === 0xfc) {
|
|
const sub = r.u();
|
|
if (sub <= 7) finding('nontrapping-fptoint (a saturating conversion)', here);
|
|
else if (sub === 8) { finding('bulk-memory (memory.init)', here); r.u(); r.byte(); }
|
|
else if (sub === 9) { finding('bulk-memory (data.drop)', here); r.u(); }
|
|
else if (sub === 10) { finding('bulk-memory (memory.copy)', here); r.byte(); r.byte(); }
|
|
else if (sub === 11) { finding('bulk-memory (memory.fill)', here); r.byte(); }
|
|
else if (sub === 12) { finding('bulk-memory (table.init)', here); r.u(); r.u(); }
|
|
else if (sub === 13) { finding('bulk-memory (elem.drop)', here); r.u(); }
|
|
else if (sub === 14) { finding('bulk-memory (table.copy)', here); r.u(); r.u(); }
|
|
else if (sub <= 17) { finding('reference-types (table.grow, table.size or table.fill)', here); r.u(); }
|
|
else { finding(`an instruction 0xfc ${sub} that 1.0 does not have`, here); return false; }
|
|
} else {
|
|
const feature = op === 0xfd ? 'simd128 (a vector instruction)'
|
|
: op === 0xfe ? 'threads (an atomic instruction)'
|
|
: op === 0xfb ? 'gc (a struct, array or cast instruction)'
|
|
: (op >= 0x06 && op <= 0x0a) || op === 0x18 || op === 0x19 || op === 0x1f ? 'exception-handling'
|
|
: op === 0x12 || op === 0x13 ? 'tail-call'
|
|
: op === 0x14 || op === 0x15 ? 'typed function references (call_ref)'
|
|
: op === 0x1c || op === 0x25 || op === 0x26 || (op >= 0xd0 && op <= 0xd6) ? 'reference-types (a typed select, a table access or a reference instruction)'
|
|
: `an instruction 0x${op.toString(16)} that 1.0 does not have`;
|
|
finding(feature, here);
|
|
return false;
|
|
}
|
|
}
|
|
if (r.b[r.end - 1] !== 0x0b) finding('a function body that does not end with end', where);
|
|
return true;
|
|
}
|
|
|
|
function section(id, r) {
|
|
const where = `the ${SECTION[id]} section`;
|
|
if (id === 1) {
|
|
for (let n = r.u(), i = 0; i < n; i++) {
|
|
const form = r.byte();
|
|
if (form !== 0x60) { finding(`a type of form 0x${form.toString(16)} (1.0 has function types only)`, `${where}, type ${i}`); throw new Error('stop'); }
|
|
const t = { params: [], results: [] };
|
|
for (let k = r.u(); k > 0; k--) { const v = r.byte(); valtype(v, `${where}, type ${i}`); t.params.push(v); }
|
|
for (let k = r.u(); k > 0; k--) { const v = r.byte(); valtype(v, `${where}, type ${i}`); t.results.push(v); }
|
|
if (t.results.length > 1) finding('multivalue (a function type with more than one result)', `${where}, type ${i}`);
|
|
types.push(t);
|
|
}
|
|
} else if (id === 2) {
|
|
for (let n = r.u(), i = 0; i < n; i++) {
|
|
const name = `${r.name()}.${r.name()}`;
|
|
imports.push(name);
|
|
const kind = r.byte();
|
|
if (kind === 0) funcTypes.push(r.u());
|
|
else if (kind === 1) {
|
|
const t = r.byte();
|
|
if (t !== 0x70) finding('reference-types (a table that is not of function references)', `${where}, ${name}`);
|
|
limits(r, 'table', `${where}, ${name}`);
|
|
tables += 1;
|
|
} else if (kind === 2) { limits(r, 'memory', `${where}, ${name}`); memories += 1; }
|
|
else if (kind === 3) {
|
|
valtype(r.byte(), `${where}, ${name}`);
|
|
const mutable = r.byte() === 1;
|
|
globalsMutable.push(mutable);
|
|
if (mutable) finding('mutable-globals (a mutable global imported)', `${where}, ${name}`);
|
|
} else { finding(`an import of kind ${kind} (1.0 has functions, tables, memories and globals)`, `${where}, ${name}`); throw new Error('stop'); }
|
|
}
|
|
} else if (id === 3) {
|
|
for (let n = r.u(); n > 0; n--) funcTypes.push(r.u());
|
|
} else if (id === 4) {
|
|
for (let n = r.u(), i = 0; i < n; i++) {
|
|
const t = r.byte();
|
|
if (t !== 0x70) finding('reference-types (a table that is not of function references)', `${where}, table ${i}`);
|
|
limits(r, 'table', `${where}, table ${i}`);
|
|
tables += 1;
|
|
}
|
|
} else if (id === 5) {
|
|
for (let n = r.u(), i = 0; i < n; i++) { limits(r, 'memory', `${where}, memory ${i}`); memories += 1; }
|
|
} else if (id === 6) {
|
|
for (let n = r.u(), i = 0; i < n; i++) {
|
|
valtype(r.byte(), `${where}, global ${i}`);
|
|
globalsMutable.push(r.byte() === 1);
|
|
constExpr(r, `${where}, global ${i}`);
|
|
}
|
|
} else if (id === 7) {
|
|
for (let n = r.u(); n > 0; n--) {
|
|
const name = r.name();
|
|
const kind = r.byte();
|
|
const index = r.u();
|
|
exports.push({ name, kind, index });
|
|
if (kind > 3) finding(`an export of kind ${kind} (1.0 has functions, tables, memories and globals)`, `${where}, ${name}`);
|
|
}
|
|
} else if (id === 8) {
|
|
r.u();
|
|
} else if (id === 9) {
|
|
for (let n = r.u(), i = 0; i < n; i++) {
|
|
const flag = r.u();
|
|
if (flag !== 0) { finding(`bulk-memory or reference-types (an element segment of kind ${flag}; 1.0 has kind 0)`, `${where}, segment ${i}`); throw new Error('stop'); }
|
|
constExpr(r, `${where}, segment ${i}`);
|
|
for (let k = r.u(); k > 0; k--) r.u();
|
|
}
|
|
} else if (id === 10) {
|
|
for (let n = r.u(), i = 0; i < n; i++) {
|
|
const f = r.sub(r.u());
|
|
const w = `function ${funcTypes.length - n + i}`;
|
|
for (let k = f.u(); k > 0; k--) { f.u(); valtype(f.byte(), `${w}, its locals`); }
|
|
bodies += 1;
|
|
body(f, w);
|
|
}
|
|
} else if (id === 11) {
|
|
for (let n = r.u(), i = 0; i < n; i++) {
|
|
const flag = r.u();
|
|
if (flag !== 0) { finding(`bulk-memory (a data segment of kind ${flag}; 1.0 has kind 0)`, `${where}, segment ${i}`); throw new Error('stop'); }
|
|
constExpr(r, `${where}, segment ${i}`);
|
|
r.skip(r.u());
|
|
}
|
|
}
|
|
}
|
|
|
|
const b = readFileSync(file);
|
|
let decoded = true;
|
|
try {
|
|
if (b.length < 8 || b.readUInt32LE(0) !== 0x6d736100) throw new Error('not a WebAssembly module (no \\0asm)');
|
|
if (b.readUInt32LE(4) !== 1) throw new Error(`binary format version ${b.readUInt32LE(4)}, not 1`);
|
|
const r = new Reader(b, 8);
|
|
let last = 0;
|
|
while (!r.eof()) {
|
|
const id = r.byte();
|
|
const s = r.sub(r.u());
|
|
if (id === 0) {
|
|
const name = s.name();
|
|
if (name === 'target_features') {
|
|
declared = [];
|
|
for (let n = s.u(); n > 0; n--) { const prefix = String.fromCharCode(s.byte()); declared.push(prefix + s.name()); }
|
|
}
|
|
continue;
|
|
}
|
|
if (id === 12) { finding('bulk-memory (a DataCount section)', 'section 12'); continue; }
|
|
if (id === 13) { finding('exception-handling (a tag section)', 'section 13'); continue; }
|
|
if (id > 13) throw new Error(`section id ${id}`);
|
|
if (id <= last) throw new Error(`section ${id} after section ${last}`);
|
|
last = id;
|
|
try {
|
|
section(id, s);
|
|
if (!s.eof()) throw new Error(`${s.end - s.at} bytes left over in the ${SECTION[id]} section`);
|
|
} catch (e) {
|
|
// A finding that ends a section's reading says so with 'stop'.
|
|
if (e.message !== 'stop') throw e;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
decoded = false;
|
|
console.error(`check-mvp: ${file}: cannot decode: ${e.message}`);
|
|
}
|
|
if (!decoded) process.exit(2);
|
|
|
|
if (tables > 1) finding('reference-types (more than one table)', 'the module');
|
|
if (memories > 1) finding('multi-memory (more than one memory)', 'the module');
|
|
for (const e of exports) {
|
|
if (e.kind === 3 && globalsMutable[e.index]) finding('mutable-globals (a mutable global exported)', `the export ${e.name}`);
|
|
}
|
|
// The linker's own account of what the module uses: nothing later than 1.0 may be in
|
|
// use ('+') or required ('='). mutable-globals is declared by every module with a
|
|
// stack pointer; the module's imports and exports were checked for it above.
|
|
for (const d of declared || []) {
|
|
if ((d[0] === '+' || d[0] === '=') && d.slice(1) !== 'mutable-globals') finding(`${d.slice(1)} (declared in use by the target_features section)`, 'the target_features section');
|
|
}
|
|
|
|
const exportedFunctions = exports.filter((e) => e.kind === 0).map((e) => e.name).sort();
|
|
const policy = [];
|
|
if (pkg) {
|
|
if (imports.length > 0) policy.push(`it imports ${imports.join(', ')}; a package imports nothing`);
|
|
for (const e of exports.filter((x) => x.kind === 0)) {
|
|
const t = types[funcTypes[e.index]];
|
|
if (t && [...t.params, ...t.results].includes(I64)) policy.push(`the export ${e.name} takes or returns i64`);
|
|
}
|
|
if (floats > 0) policy.push(`floating point: ${floats} types, constants or instructions; a package has none`);
|
|
}
|
|
if (functions) {
|
|
const want = [...functions].sort();
|
|
const extra = exportedFunctions.filter((n) => !want.includes(n));
|
|
const missing = want.filter((n) => !exportedFunctions.includes(n));
|
|
if (extra.length > 0) policy.push(`it exports ${extra.join(', ')}, which the list does not name`);
|
|
if (missing.length > 0) policy.push(`it does not export ${missing.join(', ')}`);
|
|
}
|
|
|
|
console.log(`${file}: ${b.length} bytes, SHA-256 ${createHash('sha256').update(b).digest('hex')}`);
|
|
console.log(` functions: ${bodies} bodies, ${instructions} instructions read`);
|
|
console.log(` imports: ${imports.length === 0 ? 'none' : imports.join(', ')}`);
|
|
console.log(` exported functions: ${exportedFunctions.join(', ') || 'none'}`);
|
|
console.log(` other exports: ${exports.filter((e) => e.kind !== 0).map((e) => e.name).join(', ') || 'none'}`);
|
|
console.log(` floating point: ${floats === 0 ? 'none' : `${floats} types, constants or instructions`}`);
|
|
console.log(` target_features: ${declared === null ? 'no such section' : declared.join(' ') || 'empty'}`);
|
|
if (found.size === 0 && policy.length === 0) {
|
|
console.log(' WebAssembly 1.0: every section, type, segment and instruction is 1.0\'s');
|
|
if (pkg) console.log(' a package: no imports, no i64 at an exported function, no floating point');
|
|
if (functions) console.log(' the exported functions are the list\'s');
|
|
process.exit(0);
|
|
}
|
|
for (const [feature, f] of found) console.error(` NOT 1.0: ${feature}: ${f.count} time${f.count === 1 ? '' : 's'}, first in ${f.where}`);
|
|
for (const p of policy) console.error(` NOT A PACKAGE: ${p}`);
|
|
console.error(`check-mvp: ${file} fails`);
|
|
process.exit(1);
|