From 6e9b3f605303590dd5ac7c4d4fded438eea0d559 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Fri, 31 May 2024 10:44:16 +0000 Subject: [PATCH] Initial commit --- .github/workflows/test.yaml | 16 +++ .gitignore | 1 + LICENSE | 21 +++ README.md | 81 ++++++++++++ index.js | 251 ++++++++++++++++++++++++++++++++++++ package.json | 13 ++ polyfill.js | 96 ++++++++++++++ test/check.mjs | 194 ++++++++++++++++++++++++++++ test/testsuite.revision | 1 + 9 files changed, 674 insertions(+) create mode 100644 .github/workflows/test.yaml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 index.js create mode 100644 package.json create mode 100644 polyfill.js create mode 100644 test/check.mjs create mode 100644 test/testsuite.revision diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..423c954 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,16 @@ +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: sudo apt install -y wabt + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7fef53 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/test/testsuite diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4189358 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Yuta Saito + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c9029e --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# wasm-imports-parser + +A simple parser for WebAssembly imports with [WebAssembly Type Reflection JS API](https://github.com/WebAssembly/js-types/blob/main/proposals/js-types/Overview.md) compatibility. + +Typically useful for constructing shared memory with a limit requested by imports of a WebAssembly module. + +## Installation + +``` +npm install wasm-imports-parser +``` + +## Example + + +```js +import { parseImports } from 'wasm-imports-parser'; + +const moduleBytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, // magic number + 0x01, 0x00, 0x00, 0x00, // version + // import section with one import + 0x02, // section code + 0x06, // section length + 0x01, // number of imports + 0x00, // module name length + 0x00, // field name length + 0x02, // import kind: memory + 0x00, // limits flags + 0x01, // initial pages: 1 +]); +const imports = parseImports(moduleBytes); +console.log(imports); +// > [ +// > { +// > module: '', +// > name: '', +// > kind: 'memory', +// > type: { minimum: 1, shared: false, index: 'i32' } +// > } +// > ] +``` + +## As a polyfill for [WebAssembly Type Reflection JS API](https://github.com/WebAssembly/js-types/blob/main/proposals/js-types/Overview.md) + +This parser can be used as a polyfill for the WebAssembly Type Reflection JS API. + +```js +import { polyfill } from 'wasm-imports-parser/polyfill.js'; + +const WebAssembly = polyfill(globalThis.WebAssembly); + +const moduleBytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, // magic number + 0x01, 0x00, 0x00, 0x00, // version + // import section with one import + 0x02, // section code + 0x06, // section length + 0x01, // number of imports + 0x00, // module name length + 0x00, // field name length + 0x02, // import kind: memory + 0x00, // limits flags + 0x01, // initial pages: 1 +]); +const module = await WebAssembly.compile(moduleBytes); +const imports = WebAssembly.Module.imports(module); +console.log(imports); +// > [ +// > { +// > module: '', +// > name: '', +// > kind: 'memory', +// > type: { minimum: 1, shared: false, index: 'i32' } +// > } +// > ] +``` + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/index.js b/index.js new file mode 100644 index 0000000..d6dd2d3 --- /dev/null +++ b/index.js @@ -0,0 +1,251 @@ +/** + * @typedef {"i32" | "i64" | "f32" | "f64" | "funcref" | "externref"} ValueType + * @typedef { { parameters: ValueType[], results: ValueType[] } } FunctionType + * @typedef { { element: "funcref" | "externref", minimum: number, maximum?: number } } TableType + * @typedef { { minimum: number, maximum?: number, shared: boolean, index: "i32" | "i64" } } MemoryType + * @typedef { { value: ValueType, mutable: boolean } } GlobalType + */ + +/** + * Parse a WebAssembly module bytes and return the imports entries. + * + * @param {BufferSource} moduleBytes - The WebAssembly module bytes. + * @returns { ( + * { module: string, name: string } & ( + * { kind: "function", type: FunctionType } | + * { kind: "table", type: TableType } | + * { kind: "memory", type: MemoryType } | + * { kind: "global", type: GlobalType } + * ) + * )[] } - The imports entries. + * @throws {Error} - If the module bytes are invalid. + * + * @example + * import { parseImports } from "wasm-imports-parser"; + * + * function mockImports(imports) { + * let mock = {}; + * for (const imp of imports) { + * let value; + * switch (imp.kind) { + * case "table": + * value = new WebAssembly.Table(imp.type); + * break; + * case "memory": + * value = new WebAssembly.Memory(imp.type); + * break; + * case "global": + * value = new WebAssembly.Global(imp.type, undefined); + * break; + * case "function": + * value = () => { throw "unimplemented" }; + * break; + * } + * if (! (imp.module in mock)) mock[imp.module] = {}; + * mock[imp.module][imp.name] = value; + * } + * return mock; + * } + * + * const imports = parseImports(moduleBytes); + * const importObject = mockImports(imports); + * const { instance } = await WebAssembly.instantiate(moduleBytes, importObject); + */ +export function parseImports(moduleBytes) { + const parseState = new ParseState(moduleBytes); + parseMagicNumber(parseState); + parseVersion(parseState); + + const types = []; + const imports = []; + + while (parseState.hasMoreBytes()) { + const sectionId = parseState.readByte(); + const sectionSize = parseState.readUnsignedLEB128(); + switch (sectionId) { + case 1: { + // Type section + const typeCount = parseState.readUnsignedLEB128(); + for (let i = 0; i < typeCount; i++) { + types.push(parseFunctionType(parseState)); + } + break; + } + case 2: { + // Ok, found import section + const importCount = parseState.readUnsignedLEB128(); + for (let i = 0; i < importCount; i++) { + const module = parseState.readName(); + const name = parseState.readName(); + const type = parseState.readByte(); + switch (type) { + case 0x00: + const index = parseState.readUnsignedLEB128(); + imports.push({ module, name, kind: "function", type: types[index] }); + break; + case 0x01: + imports.push({ module, name, kind: "table", type: parseTableType(parseState) }); + break; + case 0x02: + imports.push({ module, name, kind: "memory", type: parseLimits(parseState) }); + break; + case 0x03: + imports.push({ module, name, kind: "global", type: parseGlobalType(parseState) }); + break; + default: + throw new Error(`Unknown import descriptor type ${type}`); + } + } + // Skip the rest of the module + return imports; + } + default: { + parseState.skipBytes(sectionSize); + break; + } + } + } + return []; +} + +class ParseState { + constructor(moduleBytes) { + this.moduleBytes = moduleBytes; + this.offset = 0; + this.textDecoder = new TextDecoder("utf-8"); + } + + hasMoreBytes() { + return this.offset < this.moduleBytes.length; + } + + readByte() { + return this.moduleBytes[this.offset++]; + } + + skipBytes(count) { + this.offset += count; + } + + /// Read unsigned LEB128 integer + readUnsignedLEB128() { + let result = 0; + let shift = 0; + let byte; + do { + byte = this.readByte(); + result |= (byte & 0x7F) << shift; + shift += 7; + } while (byte & 0x80); + return result; + } + + readName() { + const nameLength = this.readUnsignedLEB128(); + const nameBytes = this.moduleBytes.slice(this.offset, this.offset + nameLength); + const name = this.textDecoder.decode(nameBytes); + this.offset += nameLength; + return name; + } + + assertBytes(expected) { + const baseOffset = this.offset; + const expectedLength = expected.length; + for (let i = 0; i < expectedLength; i++) { + if (this.moduleBytes[baseOffset + i] !== expected[i]) { + throw new Error(`Expected ${expected} at offset ${baseOffset}`); + } + } + this.offset += expectedLength; + } +} + +function parseMagicNumber(parseState) { + const expected = [0x00, 0x61, 0x73, 0x6D]; + parseState.assertBytes(expected); +} + +function parseVersion(parseState) { + const expected = [0x01, 0x00, 0x00, 0x00]; + parseState.assertBytes(expected); +} + +function parseTableType(parseState) { + const elementType = parseState.readByte(); + let element; + switch (elementType) { + case 0x70: + element = "funcref"; + break; + case 0x6F: + element = "externref"; + break; + default: + throw new Error(`Unknown table element type ${elementType}`); + } + const { minimum, maximum } = parseLimits(parseState); + if (maximum) { + return { element, minimum, maximum }; + } else { + return { element, minimum }; + } +} + +function parseLimits(parseState) { + const flags = parseState.readByte(); + const minimum = parseState.readUnsignedLEB128(); + const hasMaximum = flags & 1; + const shared = (flags & 2) !== 0; + const isMemory64 = (flags & 4) !== 0; + const index = isMemory64 ? "i64" : "i32"; + if (hasMaximum) { + const maximum = parseState.readUnsignedLEB128(); + return { minimum, shared, index, maximum }; + } else { + return { minimum, shared, index }; + } +} + +function parseGlobalType(parseState) { + const value = parseValueType(parseState); + const mutable = parseState.readByte() === 1; + return { value, mutable }; +} + +function parseValueType(parseState) { + const type = parseState.readByte(); + switch (type) { + case 0x7F: + return "i32"; + case 0x7E: + return "i64"; + case 0x7D: + return "f32"; + case 0x7C: + return "f64"; + case 0x70: + return "funcref"; + case 0x6f: + return "externref"; + default: + throw new Error(`Unknown value type ${type}`); + } +} + +function parseFunctionType(parseState) { + const form = parseState.readByte(); + if (form !== 0x60) { + throw new Error(`Expected function type form 0x60, got ${form}`); + } + const parameters = []; + const parameterCount = parseState.readUnsignedLEB128(); + for (let i = 0; i < parameterCount; i++) { + parameters.push(parseValueType(parseState)); + } + const results = []; + const resultCount = parseState.readUnsignedLEB128(); + for (let i = 0; i < resultCount; i++) { + results.push(parseValueType(parseState)); + } + return { parameters, results }; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c7c200a --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "wasm-imports-parser", + "version": "1.0.0", + "description": "A simple parser for WebAssembly imports", + "main": "index.js", + "type": "module", + "scripts": { + "test": "node --experimental-wasm-type-reflection test/check.mjs && node test/check.mjs" + }, + "keywords": [], + "author": "SwiftWasm Team", + "license": "MIT" +} diff --git a/polyfill.js b/polyfill.js new file mode 100644 index 0000000..bb97132 --- /dev/null +++ b/polyfill.js @@ -0,0 +1,96 @@ +import { parseImports } from "./index.js"; + +export const hasWasmTypeReflectionSupport = (() => { + const moduleBytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, // magic number + 0x01, 0x00, 0x00, 0x00, // version + // import section with one import + 0x02, // section code + 0x06, // section length + 0x01, // number of imports + 0x00, // module name length + 0x00, // field name length + 0x02, // import kind: memory + 0x00, // limits flags + 0x01, // initial pages: 1 + ]); + const module = new WebAssembly.Module(moduleBytes); + const imports = WebAssembly.Module.imports(module); + const memoryImport = imports[0]; + return typeof memoryImport.type === "object" +})(); + +/** + * Polyfill the WebAssembly object to support "type" field in the import object + * returned by `WebAssembly.Module.imports` function. + * + * @param {WebAssembly} WebAssembly + * @returns {WebAssembly} + * @example + * import fs from "fs"; + * import { polyfill } from "wasm-imports-parser/polyfill"; + * + * const WebAssembly = polyfill(globalThis.WebAssembly); + * const module = await WebAssembly.compile(fs.readFileSync(process.argv[2])); + * for (const imp of WebAssembly.Module.imports(module)) { + * console.log(imp); + * } + */ +export function polyfill(WebAssembly) { + // Check if the WebAssembly type reflection is supported. + + if (hasWasmTypeReflectionSupport) { + // If the WebAssembly type reflection is supported, no need to polyfill. + return WebAssembly; + } + + // Re-construct the WebAssembly object with the polyfill. + const newWebAssembly = {}; + // Copy all properties from the original WebAssembly object. + // Some properties are not enumerable, so we need to use Object.getOwnPropertyDescriptors. + for (const key in Object.getOwnPropertyDescriptors(WebAssembly)) { + newWebAssembly[key] = WebAssembly[key]; + } + // Hook the Module constructor to store the source bytes. + const newModule = newWebAssembly.Module = function (bytes) { + const module = new WebAssembly.Module(bytes); + module[sourceBytesSymbol] = bytes; + Object.setPrototypeOf(module, newModule.prototype); + return module; + } + Object.setPrototypeOf(newModule.prototype, WebAssembly.Module.prototype); + + // Symbol to store the source bytes inside WebAssembly.Module object. + const sourceBytesSymbol = Symbol("sourceBytes"); + + // Hook the compile function to store the source bytes. + newWebAssembly.compile = async (source) => { + const module = await WebAssembly.compile(source); + module[sourceBytesSymbol] = source; + return module; + }; + + // Hook the compileStreaming function too if supported. + if (WebAssembly.compileStreaming) { + newWebAssembly.compileStreaming = async (source) => { + const response = await source; + const clone = response.clone(); + const module = await WebAssembly.compileStreaming(response); + module[sourceBytesSymbol] = new Uint8Array(await clone.arrayBuffer()); + return module; + }; + } + + // Polyfill the WebAssembly.Module.imports function. + newModule.imports = (module) => { + const sourceBytes = module[sourceBytesSymbol]; + if (!sourceBytes) { + // If the source bytes are not available for some reason, fallback to the original function. + return WebAssembly.Module.imports(module); + } + return parseImports(sourceBytes); + }; + + return newWebAssembly; +} + diff --git a/test/check.mjs b/test/check.mjs new file mode 100644 index 0000000..0ed6909 --- /dev/null +++ b/test/check.mjs @@ -0,0 +1,194 @@ +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import { parseImports } from "../index.js"; +import { polyfill, hasWasmTypeReflectionSupport } from '../polyfill.js'; + +function run(command, args) { + console.log(`$ "${command}" ${args.map(arg => `"${arg}"`).join(" ")}`); + return execFileSync(command, args, { stdio: 'inherit' }); +} + +function prepare(testsuitePath) { + const revision = fs.readFileSync("test/testsuite.revision", "utf8").trim(); + const files = [ + "binary.wast", + "imports.wast", + "utf8-import-field.wast", + "utf8-import-module.wast", + "linking.wast", + ] + + for (const file of files) { + if (fs.existsSync(`${testsuitePath}/${file}`)) { + continue; + } + run("curl", [`https://raw.githubusercontent.com/WebAssembly/testsuite/${revision}/${file}`, "-o", `${testsuitePath}/${file}`]); + run("wast2json", [`${testsuitePath}/${file}`, "-o", `${testsuitePath}/${file}.json`]); + } +} + +function isStructurallyEqual(a, b) { + if (a === b) { + return true; + } + if (a === null || b === null || typeof a !== "object" || typeof b !== "object") { + return false; + } + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) { + return false; + } + for (const key of keysA) { + if (!keysB.includes(key) || !isStructurallyEqual(a[key], b[key])) { + return false; + } + } + return true; +} + +async function check(wasmFilePath, getImports) { + const bytes = fs.readFileSync(wasmFilePath); + let module; + try { + module = await WebAssembly.compile(bytes); + } catch { + // Skip invalid wasm files + return true; + } + const expected = WebAssembly.Module.imports(module); + const actual = parseImports(bytes); + if (actual.length !== expected.length) { + process.stdout.write("\x1b[31mF\x1b[0m\n"); + console.error(`Expected ${expected.length} imports, but got ${actual.length}`); + return false; + } + for (let i = 0; i < expected.length; i++) { + const actualImport = actual[i]; + const expectedImport = expected[i]; + if (!isStructurallyEqual(actualImport, expectedImport)) { + process.stdout.write("\x1b[31mF\x1b[0m\n"); + console.error(`Mismatch at import ${i}`); + console.error(` Expected `, expectedImport); + console.error(` Actual `, actualImport); + return false; + } + } + process.stdout.write("\x1b[32m.\x1b[0m"); + return true; +} + +async function checkPolyfill() { + const bytes = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, // magic number + 0x01, 0x00, 0x00, 0x00, // version + // import section with one import + 0x02, // section code + 0x06, // section length + 0x01, // number of imports + 0x00, // module name length + 0x00, // field name length + 0x02, // import kind: memory + 0x00, // limits flags + 0x01, // initial pages: 1 + ]); + const polyfilledWebAssembly = await polyfill(WebAssembly); + for (const getModule of [ + { + name: "new WebAssembly.Module", + async: false, + fn: () => new polyfilledWebAssembly.Module(bytes) + }, + { + name: "compile", + async: true, + fn: async () => polyfilledWebAssembly.compile(bytes) + }, + { + name: "compileStreaming", + async: true, + fn: async () => { + const headers = new Headers(); + headers.set("Content-Type", "application/wasm"); + const response = new Response(bytes, { headers: headers }); + return polyfilledWebAssembly.compileStreaming(response) + } + }, + ]) { + let imports; + try { + let module; + if (getModule.async) { + module = await getModule.fn(); + } else { + module = getModule.fn(); + } + imports = polyfilledWebAssembly.Module.imports(module); + } catch (e) { + process.stdout.write("\x1b[31mF\x1b[0m\n"); + console.error(`Failed to get imports by ${getModule.name}`); + console.error(e); + return false; + } + if (imports.length !== 1) { + process.stdout.write("\x1b[31mF\x1b[0m\n"); + return false; + } + const memoryImport = imports[0]; + if (typeof memoryImport.type !== "object") { + process.stdout.write("\x1b[31mF\x1b[0m\n"); + return false; + } + + if (memoryImport.type.minimum !== 1) { + process.stdout.write("\x1b[31mF\x1b[0m\n"); + return false; + } + + process.stdout.write("\x1b[32m.\x1b[0m"); + } + return true; +} + +async function main() { + let filesToCheck = []; + if (process.argv.length > 2) { + filesToCheck = process.argv.slice(2); + } else { + const testsuitePath = "test/testsuite"; + fs.mkdirSync(testsuitePath, { recursive: true }); + prepare(testsuitePath); + filesToCheck = fs.readdirSync(testsuitePath).filter(file => file.endsWith(".wasm")) + .map(file => `${testsuitePath}/${file}`); + } + + if (hasWasmTypeReflectionSupport) { + console.log("Checking compatibility with native implementation"); + for (const file of filesToCheck) { + try { + const ok = await check(file); + if (!ok) { + console.error(`Check failed for ${file}`); + process.exit(1); + } + } catch (e) { + process.stdout.write("\x1b[31mF\x1b[0m\n"); + console.error(`Check failed for ${file}: ${e}`); + process.exit(1); + } + } + } + + process.stdout.write("\n"); + + console.log("Checking polyfill"); + + const ok = await checkPolyfill(); + if (!ok) { + console.error("Polyfill check failed"); + process.exit(1); + } + process.stdout.write("\n"); +} + +await main(); diff --git a/test/testsuite.revision b/test/testsuite.revision new file mode 100644 index 0000000..613aa40 --- /dev/null +++ b/test/testsuite.revision @@ -0,0 +1 @@ +6dfedc8b8423a91c1dc340d3af1a7f4fbf7868b4