Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,182 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildFixtureWasm, WITNESS_OFFSET } from "./build-wasm.js";
async function instantiate(bytes: Uint8Array) {
const mod = await WebAssembly.compile(bytes);
const instance = await WebAssembly.instantiate(mod, {});
return instance.exports as Record<string, unknown> & {
memory: WebAssembly.Memory;
};
}
describe("buildFixtureWasm", () => {
it("compiles and exports the full ABI surface", {
timeout: 30000,
}, async () => {
const bytes = buildFixtureWasm();
const exports = await instantiate(bytes);
const required = [
"memory",
"__suaveplan_alloc",
"__suaveplan_free",
"__suaveplan_handle_request",
"__suaveplan_routes",
"__suaveplan_start",
"__suaveplan_dispose",
];
for (const name of required) {
expect(exports[name]).toBeDefined();
}
});
it("default routes JSON decodes to a single GET route", {
timeout: 30000,
}, async () => {
const exports = await instantiate(buildFixtureWasm());
const routes = exports.__suaveplan_routes as () => bigint;
const packed = routes();
const ptr = Number((packed >> 32n) & 0xffff_ffffn);
const len = Number(packed & 0xffff_ffffn);
const json = new TextDecoder().decode(
new Uint8Array(exports.memory.buffer, ptr, len)
);
expect(JSON.parse(json)).toEqual([{ method: "GET", path: "/hello" }]);
});
it("handle_request copies request bytes into the witness range", {
timeout: 30000,
}, async () => {
const exports = await instantiate(buildFixtureWasm());
const alloc = exports.__suaveplan_alloc as (n: number) => number;
const handle = exports.__suaveplan_handle_request as (
p: number,
l: number
) => bigint;
const payload = new TextEncoder().encode("hello-witness");
const ptr = alloc(payload.length);
new Uint8Array(exports.memory.buffer).set(payload, ptr);
handle(ptr, payload.length);
const witnessBytes = new Uint8Array(
exports.memory.buffer,
WITNESS_OFFSET,
payload.length
);
expect(new TextDecoder().decode(witnessBytes)).toBe("hello-witness");
});
it("clamps oversize requests to WITNESS_CAPACITY", {
timeout: 30000,
}, async () => {
const exports = await instantiate(buildFixtureWasm());
const alloc = exports.__suaveplan_alloc as (n: number) => number;
const handle = exports.__suaveplan_handle_request as (
p: number,
l: number
) => bigint;
const payload = new Uint8Array(2048).fill(0x41);
const ptr = alloc(payload.length);
new Uint8Array(exports.memory.buffer).set(payload, ptr);
handle(ptr, payload.length);
// First 1024 witness bytes are populated; reading the FULL
// witness range should be all 0x41 (since the clamp wrote
// exactly WITNESS_CAPACITY bytes, not more).
const witness = new Uint8Array(
exports.memory.buffer,
WITNESS_OFFSET,
1024
);
expect(witness.every((b) => b === 0x41)).toBe(true);
});
it("allocAlwaysZero produces an alloc that returns 0", {
timeout: 30000,
}, async () => {
const exports = await instantiate(
buildFixtureWasm({ allocAlwaysZero: true })
);
const alloc = exports.__suaveplan_alloc as (n: number) => number;
expect(alloc(16)).toBe(0);
});
it("handlerTraps fixture traps via unreachable", {
timeout: 30000,
}, async () => {
const exports = await instantiate(
buildFixtureWasm({ handlerTraps: true })
);
const handle = exports.__suaveplan_handle_request as (
p: number,
l: number
) => bigint;
expect(() => handle(0, 0)).toThrow(WebAssembly.RuntimeError);
});
it("startTraps + disposeTraps both surface as RuntimeError", {
timeout: 30000,
}, async () => {
const startExports = await instantiate(
buildFixtureWasm({ startTraps: true })
);
expect(() => (startExports.__suaveplan_start as () => void)()).toThrow(
WebAssembly.RuntimeError
);
const disposeExports = await instantiate(
buildFixtureWasm({ disposeTraps: true })
);
expect(() =>
(disposeExports.__suaveplan_dispose as () => void)()
).toThrow(WebAssembly.RuntimeError);
});
it("routesTraps fixture traps __suaveplan_routes", {
timeout: 30000,
}, async () => {
const exports = await instantiate(
buildFixtureWasm({ routesTraps: true })
);
expect(() => (exports.__suaveplan_routes as () => bigint)()).toThrow(
WebAssembly.RuntimeError
);
});
it("routesEmpty returns (0, 0)", { timeout: 30000 }, async () => {
const exports = await instantiate(
buildFixtureWasm({ routesEmpty: true })
);
expect((exports.__suaveplan_routes as () => bigint)()).toBe(0n);
});
it("handlerEmptyResponse returns (0, 0)", { timeout: 30000 }, async () => {
const exports = await instantiate(
buildFixtureWasm({ handlerEmptyResponse: true })
);
const handle = exports.__suaveplan_handle_request as (
p: number,
l: number
) => bigint;
expect(handle(0, 0)).toBe(0n);
});
it("dropExports omits the requested symbols", {
timeout: 30000,
}, async () => {
const exports = await instantiate(
buildFixtureWasm({ dropExports: ["__suaveplan_start"] })
);
expect(exports.__suaveplan_start).toBeUndefined();
// Memory and the rest remain.
expect(exports.memory).toBeInstanceOf(WebAssembly.Memory);
});
it("rejects oversize routes JSON", { timeout: 5000 }, () => {
expect(() =>
buildFixtureWasm({ routesJson: "x".repeat(2048) })
).toThrow(RangeError);
});
it("rejects oversize response JSON", { timeout: 5000 }, () => {
expect(() =>
buildFixtureWasm({ responseJson: "x".repeat(2048) })
).toThrow(RangeError);
});
});

View File

@@ -0,0 +1,455 @@
/**
* Hand-encoded WASM fixture builder for the wasm host bridge's tests +
* conformance harness. Produces deterministic, validator-clean WASM
* modules implementing the `__suaveplan_*` ABI surface with configurable
* behaviour per export so the test suite can exercise every bridge
* production path WITHOUT pulling in `wabt` or any other external WASM
* build tool.
*
* Memory layout (single 1-page memory):
* [ 0, 1024) — reserved scratch (cleared on init).
* [1024, 1024 + routesBytes.length) — routes JSON.
* [2048, 2048 + responseBytes.length) — canned response JSON.
* [3072, 3072 + WITNESS_CAPACITY) — request-byte witness.
* [4096, 65536) — bump allocator arena.
*
* Global 0: mut i32 = 4096 (bump pointer).
*/
import {
concat,
encodeI32,
encodeName,
encodeU32,
section,
vec,
} from "./leb128.js";
import {
BLOCK_TYPE_EMPTY,
funcBody,
OP_BLOCK,
OP_END,
OP_I32_ADD,
OP_I32_CONST,
OP_I32_EQZ,
OP_I32_LT_U,
OP_I64_CONST,
OP_LOOP,
OP_UNREACHABLE,
opBr,
opBrIf,
opCall,
opGlobalGet,
opGlobalSet,
opI32Const,
opI32Load8U,
opI32Store8,
opLocalGet,
opLocalSet,
VT_I32,
VT_I64,
} from "./wasm-opcodes.js";
const ROUTES_OFFSET = 1024;
const RESPONSE_OFFSET = 2048;
export const WITNESS_OFFSET = 3072;
export const WITNESS_CAPACITY = 1024;
const HEAP_START = 4096;
const MAGIC = new Uint8Array([0x00, 0x61, 0x73, 0x6d]);
const VERSION = new Uint8Array([0x01, 0x00, 0x00, 0x00]);
/** Configurable behaviour per ABI export. */
export interface BuildFixtureWasmOptions {
/**
* The JSON-encoded `SerializedRoute[]` the guest's
* `__suaveplan_routes` returns. Defaults to a single-route catalog.
*/
readonly routesJson?: string;
/**
* The JSON-encoded `SerializedResponse` the guest's
* `__suaveplan_handle_request` returns. Defaults to a 200 with an
* empty body.
*/
readonly responseJson?: string;
/** Force `__suaveplan_alloc` to return 0 (OOM). */
readonly allocAlwaysZero?: boolean;
/** Force `__suaveplan_handle_request` to execute `unreachable`. */
readonly handlerTraps?: boolean;
/** Force `__suaveplan_start` to execute `unreachable`. */
readonly startTraps?: boolean;
/** Force `__suaveplan_dispose` to execute `unreachable`. */
readonly disposeTraps?: boolean;
/** Force `__suaveplan_routes` to execute `unreachable`. */
readonly routesTraps?: boolean;
/** Force `__suaveplan_routes` to return `(0n, 0n)` packed. */
readonly routesEmpty?: boolean;
/** Force `__suaveplan_handle_request` to return `(0n, 0n)` packed. */
readonly handlerEmptyResponse?: boolean;
/**
* Force `__suaveplan_handle_request` to return `(ptr, 0)` packed
* with the supplied non-zero pointer. Used to test the
* `(nonzero, 0)` empty-response branch where the bridge must free
* the dangling pointer.
*/
readonly handlerZeroLengthResponse?: number;
/** Drop one or more required exports — exercises the ABI validator. */
readonly dropExports?: readonly (
| "memory"
| "__suaveplan_alloc"
| "__suaveplan_free"
| "__suaveplan_handle_request"
| "__suaveplan_routes"
| "__suaveplan_start"
| "__suaveplan_dispose"
)[];
}
const ENCODER = new TextEncoder();
const DEFAULT_ROUTES_JSON = JSON.stringify([{ method: "GET", path: "/hello" }]);
const DEFAULT_RESPONSE_JSON = JSON.stringify({
status: 200,
headers: [["content-type", "text/plain"]],
bodyBase64: btoa("ok")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, ""),
});
/**
* Build a validator-clean WASM module. The returned bytes are accepted
* by `WebAssembly.compile` and instantiate against an empty imports
* object (no WASI, no capability imports — the fixture is a pure
* compute module).
*/
export function buildFixtureWasm(
options: BuildFixtureWasmOptions = {}
): Uint8Array {
const routesBytes = ENCODER.encode(
options.routesJson ?? DEFAULT_ROUTES_JSON
);
const responseBytes = ENCODER.encode(
options.responseJson ?? DEFAULT_RESPONSE_JSON
);
if (routesBytes.length > RESPONSE_OFFSET - ROUTES_OFFSET) {
throw new RangeError("routesJson too large for fixture memory layout");
}
if (responseBytes.length > WITNESS_OFFSET - RESPONSE_OFFSET) {
throw new RangeError(
"responseJson too large for fixture memory layout"
);
}
const dropExports = new Set(options.dropExports ?? []);
// --- Type section ---------------------------------------------------
// type 0: () -> ()
// type 1: (i32) -> i32
// type 2: (i32) -> ()
// type 3: (i32, i32) -> i64
// type 4: () -> i64
// type 5: (i32, i32, i32) -> () [memcpy helper]
const typeSection = section(
0x01,
vec([
new Uint8Array([0x60, 0x00, 0x00]),
new Uint8Array([0x60, 0x01, VT_I32, 0x01, VT_I32]),
new Uint8Array([0x60, 0x01, VT_I32, 0x00]),
new Uint8Array([0x60, 0x02, VT_I32, VT_I32, 0x01, VT_I64]),
new Uint8Array([0x60, 0x00, 0x01, VT_I64]),
new Uint8Array([0x60, 0x03, VT_I32, VT_I32, VT_I32, 0x00]),
])
);
// --- Function section (declares the type index of each defined fn) -
// Function indices (no imports):
// 0: __suaveplan_alloc — type 1
// 1: __suaveplan_free — type 2
// 2: __suaveplan_handle_request — type 3
// 3: __suaveplan_routes — type 4
// 4: __suaveplan_start — type 0
// 5: __suaveplan_dispose — type 0
// 6: memcpy_internal — type 5
const funcSection = section(
0x03,
vec([
encodeU32(1),
encodeU32(2),
encodeU32(3),
encodeU32(4),
encodeU32(0),
encodeU32(0),
encodeU32(5),
])
);
// --- Memory section -------------------------------------------------
// One memory with initial=1 page, no max.
const memorySection = section(0x05, vec([new Uint8Array([0x00, 0x01])]));
// --- Global section -------------------------------------------------
// Global 0: (mut i32) = HEAP_START
const globalSection = section(
0x06,
vec([
concat(
new Uint8Array([VT_I32, 0x01]),
new Uint8Array([OP_I32_CONST]),
encodeI32(HEAP_START),
new Uint8Array([OP_END])
),
])
);
// --- Export section -------------------------------------------------
const exportEntries: Uint8Array[] = [];
const pushExport = (name: string, kind: number, index: number): void => {
if (dropExports.has(name as never)) return;
exportEntries.push(
concat(encodeName(name), new Uint8Array([kind, index]))
);
};
// 0x00 = function, 0x02 = memory.
pushExport("memory", 0x02, 0);
pushExport("__suaveplan_alloc", 0x00, 0);
pushExport("__suaveplan_free", 0x00, 1);
pushExport("__suaveplan_handle_request", 0x00, 2);
pushExport("__suaveplan_routes", 0x00, 3);
pushExport("__suaveplan_start", 0x00, 4);
pushExport("__suaveplan_dispose", 0x00, 5);
const exportSection = section(0x07, vec(exportEntries));
// --- Code section ---------------------------------------------------
const codeSection = section(
0x0a,
vec([
buildAllocBody(options.allocAlwaysZero === true),
buildFreeBody(),
buildHandleRequestBody({
trap: options.handlerTraps === true,
emptyResponse: options.handlerEmptyResponse === true,
zeroLengthPtr: options.handlerZeroLengthResponse,
responseBytesLen: responseBytes.length,
}),
buildRoutesBody({
trap: options.routesTraps === true,
empty: options.routesEmpty === true,
routesLen: routesBytes.length,
}),
buildLifecycleBody(options.startTraps === true),
buildLifecycleBody(options.disposeTraps === true),
buildMemcpyBody(),
])
);
// --- Data section ---------------------------------------------------
const dataSection = section(
0x0b,
vec([
buildDataSegment(ROUTES_OFFSET, routesBytes),
buildDataSegment(RESPONSE_OFFSET, responseBytes),
])
);
return concat(
MAGIC,
VERSION,
typeSection,
funcSection,
memorySection,
globalSection,
exportSection,
codeSection,
dataSection
);
}
function buildDataSegment(offset: number, payload: Uint8Array): Uint8Array {
// Active data segment, memory index 0, init expr (i32.const offset, end), bytes.
return concat(
encodeU32(0), // memory index
new Uint8Array([OP_I32_CONST]),
encodeI32(offset),
new Uint8Array([OP_END]),
encodeU32(payload.length),
payload
);
}
function buildAllocBody(zero: boolean): Uint8Array {
// (param i32) -> i32
// local 0 = len
if (zero) {
return funcBody([], concat(opI32Const(0)));
}
// ptr = global; global = ptr + len; return ptr
// locals: ptr (local 1)
const body = concat(
opGlobalGet(0),
opLocalSet(1),
opLocalGet(1),
opLocalGet(0),
new Uint8Array([OP_I32_ADD]),
opGlobalSet(0),
opLocalGet(1)
);
return funcBody([{ count: 1, type: VT_I32 }], body);
}
function buildFreeBody(): Uint8Array {
// (param i32) -> ()
// No-op — emit just end (handled by funcBody).
return funcBody([], new Uint8Array());
}
function buildHandleRequestBody(options: {
readonly trap: boolean;
readonly emptyResponse: boolean;
readonly zeroLengthPtr: number | undefined;
readonly responseBytesLen: number;
}): Uint8Array {
// (param i32 i32) -> i64
// local 0 = reqPtr, local 1 = reqLen
if (options.trap) {
return funcBody([], new Uint8Array([OP_UNREACHABLE]));
}
if (options.emptyResponse) {
return funcBody(
[],
concat(new Uint8Array([OP_I64_CONST]), encodeI32(0))
);
}
if (options.zeroLengthPtr !== undefined) {
const packed = BigInt(options.zeroLengthPtr) << 32n;
return funcBody(
[],
concat(new Uint8Array([OP_I64_CONST]), encodeI64(packed))
);
}
// 1. Clamp reqLen to WITNESS_CAPACITY.
// 2. Call memcpy_internal(WITNESS, reqPtr, reqLen).
// 3. Return packed (RESPONSE_OFFSET << 32) | responseBytesLen as i64.
const clipBlock = concat(
new Uint8Array([OP_BLOCK, BLOCK_TYPE_EMPTY]),
opLocalGet(1),
opI32Const(WITNESS_CAPACITY),
new Uint8Array([OP_I32_LT_U]),
opBrIf(0),
opI32Const(WITNESS_CAPACITY),
opLocalSet(1),
new Uint8Array([OP_END])
);
const callMemcpy = concat(
opI32Const(WITNESS_OFFSET), // dst
opLocalGet(0), // src = reqPtr
opLocalGet(1), // len (possibly clamped)
opCall(6) // memcpy_internal
);
const packed =
(BigInt(RESPONSE_OFFSET) << 32n) | BigInt(options.responseBytesLen);
const returnPacked = concat(
new Uint8Array([OP_I64_CONST]),
encodeI64(packed)
);
return funcBody([], concat(clipBlock, callMemcpy, returnPacked));
}
function buildRoutesBody(options: {
readonly trap: boolean;
readonly empty: boolean;
readonly routesLen: number;
}): Uint8Array {
// () -> i64
if (options.trap) {
return funcBody([], new Uint8Array([OP_UNREACHABLE]));
}
if (options.empty) {
return funcBody(
[],
concat(new Uint8Array([OP_I64_CONST]), encodeI64(0n))
);
}
const packed = (BigInt(ROUTES_OFFSET) << 32n) | BigInt(options.routesLen);
return funcBody(
[],
concat(new Uint8Array([OP_I64_CONST]), encodeI64(packed))
);
}
function buildLifecycleBody(trap: boolean): Uint8Array {
if (trap) {
return funcBody([], new Uint8Array([OP_UNREACHABLE]));
}
return funcBody([], new Uint8Array());
}
function buildMemcpyBody(): Uint8Array {
// (param dst i32, src i32, len i32) -> ()
// local 3 = i
// block
// loop
// local.get i
// local.get len
// i32.lt_u
// i32.eqz
// br_if 1
// ;; memory[dst+i] = memory[src+i]
// local.get dst
// local.get i
// i32.add
// local.get src
// local.get i
// i32.add
// i32.load8_u
// i32.store8
// local.get i
// i32.const 1
// i32.add
// local.set i
// br 0
// end
// end
const body = concat(
opI32Const(0),
opLocalSet(3),
new Uint8Array([OP_BLOCK, BLOCK_TYPE_EMPTY]),
new Uint8Array([OP_LOOP, BLOCK_TYPE_EMPTY]),
opLocalGet(3),
opLocalGet(2),
new Uint8Array([OP_I32_LT_U]),
new Uint8Array([OP_I32_EQZ]),
opBrIf(1),
opLocalGet(0),
opLocalGet(3),
new Uint8Array([OP_I32_ADD]),
opLocalGet(1),
opLocalGet(3),
new Uint8Array([OP_I32_ADD]),
opI32Load8U(),
opI32Store8(),
opLocalGet(3),
opI32Const(1),
new Uint8Array([OP_I32_ADD]),
opLocalSet(3),
opBr(0),
new Uint8Array([OP_END]),
new Uint8Array([OP_END])
);
return funcBody([{ count: 1, type: VT_I32 }], body);
}
/** Encode a signed LEB128 i64 literal. */
function encodeI64(value: bigint): Uint8Array {
const out: number[] = [];
let v = value;
while (true) {
const byte = Number(v & 0x7fn);
v >>= 7n;
const signBit = (byte & 0x40) !== 0;
if ((v === 0n && !signBit) || (v === -1n && signBit)) {
out.push(byte);
break;
}
out.push(byte | 0x80);
}
return new Uint8Array(out);
}

View File

@@ -0,0 +1,69 @@
/**
* Minimal LEB128 encoder utilities used by the hand-encoded WASM fixture
* builder. WebAssembly's binary format uses LEB128 for unsigned 32-bit
* indices / lengths and signed LEB128 for `i32.const` / `i64.const`
* literals. Both encoders are bounded by their bit-width and emit the
* minimal number of bytes.
*/
/** Encode `value` as unsigned LEB128. */
export function encodeU32(value: number): Uint8Array {
if (value < 0 || !Number.isFinite(value)) {
throw new RangeError(`encodeU32: invalid value ${value}`);
}
const out: number[] = [];
let v = value >>> 0;
do {
let byte = v & 0x7f;
v >>>= 7;
if (v !== 0) byte |= 0x80;
out.push(byte);
} while (v !== 0);
return new Uint8Array(out);
}
/** Encode `value` as signed LEB128 (32-bit). */
export function encodeI32(value: number): Uint8Array {
const out: number[] = [];
let v = value | 0;
while (true) {
const byte = v & 0x7f;
v >>= 7;
const signBit = (byte & 0x40) !== 0;
if ((v === 0 && !signBit) || (v === -1 && signBit)) {
out.push(byte);
break;
}
out.push(byte | 0x80);
}
return new Uint8Array(out);
}
/** Encode a name as length-prefixed UTF-8 bytes. */
export function encodeName(name: string): Uint8Array {
const bytes = new TextEncoder().encode(name);
return concat(encodeU32(bytes.length), bytes);
}
/** Concatenate any number of byte arrays. */
export function concat(...parts: Uint8Array[]): Uint8Array {
let total = 0;
for (const p of parts) total += p.length;
const out = new Uint8Array(total);
let offset = 0;
for (const p of parts) {
out.set(p, offset);
offset += p.length;
}
return out;
}
/** Wrap raw payload bytes in a WASM section frame: id + length + payload. */
export function section(id: number, payload: Uint8Array): Uint8Array {
return concat(new Uint8Array([id]), encodeU32(payload.length), payload);
}
/** Encode a count-prefixed vector of pre-encoded items. */
export function vec(items: readonly Uint8Array[]): Uint8Array {
return concat(encodeU32(items.length), concat(...items));
}

View File

@@ -0,0 +1,482 @@
/**
* Hand-encoded WASM fixture builder for the WASI-stdio variant of the
* bridge. Produces a validator-clean module that imports the WASI
* snapshot-preview1 surface the bridge expects (`fd_read`, `fd_write`,
* `environ_get`, `environ_sizes_get`, `proc_exit`) and exports the
* required `memory` + `_start` pair.
*
* Per-call protocol: the guest's `_start` is invoked once per envelope
* exchange by the host. Each invocation:
* 1. Calls `fd_read` to drain stdin into a scratch buffer at offset
* 0 (the host queues exactly one frame before calling `_start`,
* so the read returns the full frame).
* 2. Optionally executes `unreachable` (trap-on-call-N variants).
* 3. Picks one of up to four pre-staged reply frames based on a
* mutable global call counter.
* 4. Calls `fd_write(fd=1, ...)` with the chosen reply frame.
* 5. Increments the call counter and returns.
*
* The four reply slots map to the protocol steps in order:
* slot 0 → routes reply
* slot 1 → start ok
* slot 2 → request reply (or trap, per `trapOnRequest`)
* slot 3 → dispose ok
*
* Memory layout (single 1-page memory, 64 KiB):
* [ 0, 16) — fd_read iov[0] = { ptr=128, len=4096 }
* fd_write iov[0] = { ptr=?, len=? } (overwritten)
* [ 16, 20) — nread / nwritten scratch
* [ 128, 4224) — stdin scratch buffer
* [5000, 5004) — call counter (global stash)
* [8192, 8192 + reply0Bytes.length) — routes reply
* [12288, 12288 + reply1Bytes.length) — start ok
* [16384, 16384 + reply2Bytes.length) — request reply
* [20480, 20480 + reply3Bytes.length) — dispose ok
*
* Globals:
* global 0 (mut i32) — call counter (default 0).
*/
import {
concat,
encodeI32,
encodeName,
encodeU32,
section,
vec,
} from "../leb128.js";
import {
BLOCK_TYPE_EMPTY,
funcBody,
OP_BLOCK,
OP_CALL,
OP_DROP,
OP_END,
OP_I32_ADD,
OP_I32_CONST,
OP_I32_EQZ,
OP_I32_STORE,
OP_I32_SUB,
OP_UNREACHABLE,
opBrIf,
opGlobalGet,
opGlobalSet,
opI32Const,
opLocalGet,
opLocalSet,
VT_I32,
} from "../wasm-opcodes.js";
const STDIN_PTR = 128;
const STDIN_LEN = 4096;
const NREAD_PTR = 16;
// Each reply slot has 4096 bytes of headroom.
const REPLY_SLOTS = [8192, 12288, 16384, 20480] as const;
const MAGIC = new Uint8Array([0x00, 0x61, 0x73, 0x6d]);
const VERSION = new Uint8Array([0x01, 0x00, 0x00, 0x00]);
const ENCODER = new TextEncoder();
/** Configurable behaviour for the WASI-stdio fixture. */
export interface BuildWasiStdioFixtureOptions {
/** JSON-encoded routes reply payload. Default: 1 route. */
readonly routesReply?: string;
/** JSON-encoded ok-shaped reply for `{kind:"start"}`. Default: `{"kind":"ok"}`. */
readonly startReply?: string;
/** JSON-encoded request reply (PluginEnvelopeOut). Default: 200 ok body. */
readonly requestReply?: string;
/** JSON-encoded reply for `{kind:"dispose"}`. Default: `{"kind":"ok"}`. */
readonly disposeReply?: string;
/** If set, `_start` invokes `unreachable` on the given (0-indexed) call. */
readonly trapOnCall?: 0 | 1 | 2 | 3;
/** Drop one of the required imports — exercises the ABI validator. */
readonly dropImports?: readonly (
| "fd_read"
| "fd_write"
| "environ_get"
| "environ_sizes_get"
| "proc_exit"
)[];
/** Drop a required export — exercises the ABI validator. */
readonly dropExports?: readonly ("memory" | "_start")[];
/** Emit a malformed (non-JSON) reply on the request slot. */
readonly requestReplyRaw?: Uint8Array;
/**
* Make `_start` return without writing any frame to fd 1 — used by
* the runner's STDIO_TIMEOUT test.
*/
readonly silentStart?: boolean;
/**
* Add an extra import from an unknown module — used by the
* runner's instantiation_failed test.
*/
readonly addExtraUnknownImport?: boolean;
/**
* Have `_start` invoke `environ_sizes_get` (via memoryRef) once
* per call. Used to drive the runner's memoryRef thunk coverage.
*/
readonly callEnvironSizesGet?: boolean;
}
const DEFAULT_ROUTES_REPLY = JSON.stringify({
kind: "routes",
routes: [
{ method: "GET", path: "/hello" },
{ method: "POST", path: "/echo" },
],
});
const DEFAULT_OK_REPLY = JSON.stringify({ kind: "ok" });
const DEFAULT_REQUEST_REPLY = JSON.stringify({
kind: "response",
response: {
status: 200,
headers: [["content-type", "text/plain"]],
bodyBase64: btoa("ok")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, ""),
},
});
/** Build a length-prefixed frame `[u32 BE len][JSON bytes]`. */
function frame(json: string | Uint8Array): Uint8Array {
const payload = typeof json === "string" ? ENCODER.encode(json) : json;
const out = new Uint8Array(4 + payload.length);
new DataView(out.buffer).setUint32(0, payload.length, false);
out.set(payload, 4);
return out;
}
/**
* Build the WASI-stdio fixture WASM module. The returned bytes are
* accepted by `WebAssembly.compile` and instantiate against an imports
* object exposing `wasi_snapshot_preview1.{fd_read, fd_write,
* environ_get, environ_sizes_get, proc_exit}`.
*/
export function buildWasiStdioFixtureWasm(
options: BuildWasiStdioFixtureOptions = {}
): Uint8Array {
const replies: ReadonlyArray<Uint8Array> = [
frame(options.routesReply ?? DEFAULT_ROUTES_REPLY),
frame(options.startReply ?? DEFAULT_OK_REPLY),
options.requestReplyRaw !== undefined
? frame(options.requestReplyRaw)
: frame(options.requestReply ?? DEFAULT_REQUEST_REPLY),
frame(options.disposeReply ?? DEFAULT_OK_REPLY),
];
for (let i = 0; i < replies.length; i++) {
const r = replies[i] as Uint8Array;
if (r.length > 4096) {
throw new RangeError(`reply slot ${i} payload too large`);
}
}
const dropImports = new Set(options.dropImports ?? []);
const dropExports = new Set(options.dropExports ?? []);
// Type section.
// 0: (i32, i32, i32, i32) → i32 — fd_read / fd_write signature.
// 1: (i32, i32) → i32 — environ_get / environ_sizes_get.
// 2: (i32) → () — proc_exit.
// 3: () → () — _start.
const typeSection = section(
0x01,
vec([
new Uint8Array([
0x60,
0x04,
VT_I32,
VT_I32,
VT_I32,
VT_I32,
0x01,
VT_I32,
]),
new Uint8Array([0x60, 0x02, VT_I32, VT_I32, 0x01, VT_I32]),
new Uint8Array([0x60, 0x01, VT_I32, 0x00]),
new Uint8Array([0x60, 0x00, 0x00]),
])
);
// Import section.
const importEntries: Uint8Array[] = [];
const pushImport = (name: string, typeIdx: number): void => {
if (dropImports.has(name as never)) return;
importEntries.push(
concat(
encodeName("wasi_snapshot_preview1"),
encodeName(name),
new Uint8Array([0x00]), // kind = function
encodeU32(typeIdx)
)
);
};
pushImport("fd_read", 0);
pushImport("fd_write", 0);
pushImport("environ_get", 1);
pushImport("environ_sizes_get", 1);
pushImport("proc_exit", 2);
if (options.addExtraUnknownImport === true) {
importEntries.push(
concat(
encodeName("unknown_module"),
encodeName("missing_fn"),
new Uint8Array([0x00]),
encodeU32(2)
)
);
}
const importedCount = importEntries.length;
const importSection = section(0x02, vec(importEntries));
// Function section — one defined function: `_start` (type 3).
const funcSection = section(0x03, vec([encodeU32(3)]));
// Memory section — 1 page (64 KiB).
const memorySection = section(0x05, vec([new Uint8Array([0x00, 0x01])]));
// Global section — global 0 (mut i32) = 0.
const globalSection = section(
0x06,
vec([
concat(
new Uint8Array([VT_I32, 0x01]),
new Uint8Array([OP_I32_CONST]),
encodeI32(0),
new Uint8Array([OP_END])
),
])
);
// Export section.
const exportEntries: Uint8Array[] = [];
if (!dropExports.has("memory")) {
exportEntries.push(
concat(encodeName("memory"), new Uint8Array([0x02, 0x00]))
);
}
if (!dropExports.has("_start")) {
// _start is the only defined function — its index is `importedCount + 0`.
exportEntries.push(
concat(
encodeName("_start"),
new Uint8Array([0x00]),
encodeU32(importedCount)
)
);
}
const exportSection = section(0x07, vec(exportEntries));
// Code section — _start body.
const startBody = buildStartBody({
importedCount,
replies,
trapOnCall: options.trapOnCall,
haveFdRead: !dropImports.has("fd_read"),
haveFdWrite:
!dropImports.has("fd_write") && options.silentStart !== true,
callEnvironSizesGet: options.callEnvironSizesGet === true,
});
const codeSection = section(0x0a, vec([startBody]));
// Data section — pre-stage each reply frame at its slot offset.
const dataSegments: Uint8Array[] = [];
for (let i = 0; i < replies.length; i++) {
const slot = REPLY_SLOTS[i] as number;
dataSegments.push(
concat(
encodeU32(0),
new Uint8Array([OP_I32_CONST]),
encodeI32(slot),
new Uint8Array([OP_END]),
encodeU32((replies[i] as Uint8Array).length),
replies[i] as Uint8Array
)
);
}
const dataSection = section(0x0b, vec(dataSegments));
return concat(
MAGIC,
VERSION,
typeSection,
importSection,
funcSection,
memorySection,
globalSection,
exportSection,
codeSection,
dataSection
);
}
interface StartBodyOptions {
readonly importedCount: number;
readonly replies: ReadonlyArray<Uint8Array>;
readonly trapOnCall: number | undefined;
readonly haveFdRead: boolean;
readonly haveFdWrite: boolean;
readonly callEnvironSizesGet: boolean;
}
/**
* Build the `_start` function body. Locals: `local 0` = call index
* (cached from global). The function logic is:
*
* local0 = global0
* if (trapOnCall === local0) unreachable
*
* // 1. fd_read(fd=0, iovs=stdinIovs, iovsLen=1, nreadPtr=NREAD_PTR)
* // 2. select reply slot by local0 (4-arm switch)
* // 3. fd_write(fd=1, iovs=writeIovs, iovsLen=1, nwrittenPtr=NREAD_PTR)
* //
* // global0 = local0 + 1
* //
* The fixture writes the WHOLE pre-staged frame on every call. We use
* scratch memory cells to set up iovec records before each WASI call.
*/
function buildStartBody(opts: StartBodyOptions): Uint8Array {
const { importedCount, replies, trapOnCall, haveFdRead, haveFdWrite } =
opts;
const _fdReadIdx = importedCount > 0 ? 0 : -1; // first imported = fd_read
const fdWriteIdx = 1;
void fdWriteIdx;
// Iovec scratch at memory[0..16]:
// [ 0, 4) = ptr (stdinPtr=128 for fd_read; replySlot for fd_write)
// [ 4, 8) = len (STDIN_LEN for fd_read; reply.length for fd_write)
//
// NREAD_PTR / NWRITTEN_PTR scratch at memory[16..20].
// 1) fd_read iovec setup
const setupReadIov = concat(
opI32Const(0),
opI32Const(STDIN_PTR),
new Uint8Array([OP_I32_STORE]),
encodeU32(2),
encodeU32(0),
opI32Const(4),
opI32Const(STDIN_LEN),
new Uint8Array([OP_I32_STORE]),
encodeU32(2),
encodeU32(0)
);
// fd_read call: result = fd_read(0, 0, 1, NREAD_PTR); drop;
const callFdRead = concat(
opI32Const(0), // fd = 0 (stdin)
opI32Const(0), // iovs ptr = 0
opI32Const(1), // iovs len
opI32Const(NREAD_PTR), // nread ptr
new Uint8Array([OP_CALL]),
encodeU32(0), // imported fd_read at function index 0 (if not dropped)
new Uint8Array([OP_DROP])
);
// 4-arm dispatch: local 0 holds the call index.
// We use chained br_if blocks: if local0 === 0 → load slot 0 + jump out, etc.
const dispatchBlocks: Uint8Array[] = [];
for (let slot = 0; slot < 4; slot++) {
const replyLen = replies[slot]?.length ?? 0;
const replyPtr = REPLY_SLOTS[slot] as number;
// Block: if local0 !== slot, br 0 (skip); else set iovec + br outer.
// Encoded as a single block with a conditional skip.
// We'll encode each arm as: (block (local.get 0) (i32.const slot) (i32.sub) (br_if 0) <setup+write> (br outer))
// outer block depth: we'll wrap the whole switch in one outer block (depth 1).
const arm = concat(
new Uint8Array([OP_BLOCK, BLOCK_TYPE_EMPTY]),
opLocalGet(0),
opI32Const(slot),
new Uint8Array([OP_I32_SUB]),
opBrIf(0), // if local0 - slot !== 0 (truthy) → break inner block
// Setup write iovec: mem[0..4] = replyPtr; mem[4..8] = replyLen
opI32Const(0),
opI32Const(replyPtr),
new Uint8Array([OP_I32_STORE]),
encodeU32(2),
encodeU32(0),
opI32Const(4),
opI32Const(replyLen),
new Uint8Array([OP_I32_STORE]),
encodeU32(2),
encodeU32(0),
// Call fd_write(1, 0, 1, NREAD_PTR); drop.
opI32Const(1),
opI32Const(0),
opI32Const(1),
opI32Const(NREAD_PTR),
new Uint8Array([OP_CALL]),
encodeU32(1), // imported fd_write at function index 1
new Uint8Array([OP_DROP]),
// Break the OUTER switch block (depth 1: we're inside arm-block inside outer).
new Uint8Array([0x0c, 0x01]), // OP_BR depth=1
new Uint8Array([OP_END])
);
dispatchBlocks.push(arm);
}
const switchBody = concat(
new Uint8Array([OP_BLOCK, BLOCK_TYPE_EMPTY]),
...dispatchBlocks,
new Uint8Array([OP_END])
);
// Optional trap injection: if trapOnCall === local0, hit unreachable.
let trapGuard: Uint8Array = new Uint8Array();
if (trapOnCall !== undefined) {
trapGuard = concat(
new Uint8Array([OP_BLOCK, BLOCK_TYPE_EMPTY]),
opLocalGet(0),
opI32Const(trapOnCall),
new Uint8Array([OP_I32_SUB]),
new Uint8Array([OP_I32_EQZ]),
new Uint8Array([OP_I32_EQZ]),
opBrIf(0), // skip unreachable if !== trapOnCall
new Uint8Array([OP_UNREACHABLE]),
new Uint8Array([OP_END])
);
}
// Increment the call counter EARLY so a trap on call N does not
// leave global0 stuck at N (which would re-trap dispose).
const incrementCounter = concat(
opLocalGet(0),
opI32Const(1),
new Uint8Array([OP_I32_ADD]),
opGlobalSet(0)
);
// Optional: invoke environ_sizes_get(20, 24) — pushes the
// memoryRef thunk in the runner. environ_sizes_get is import #3
// (fd_read=0, fd_write=1, environ_get=2, environ_sizes_get=3).
const optionalEnvironCall = opts.callEnvironSizesGet
? concat(
opI32Const(20),
opI32Const(24),
new Uint8Array([OP_CALL]),
encodeU32(3),
new Uint8Array([OP_DROP])
)
: new Uint8Array();
// Compose: load counter; increment; trap guard; fd_read; switch.
const loadCounter = concat(opGlobalGet(0), opLocalSet(0));
const body = concat(
loadCounter,
incrementCounter,
trapGuard,
haveFdRead ? concat(setupReadIov, callFdRead) : new Uint8Array(),
optionalEnvironCall,
haveFdWrite ? switchBody : new Uint8Array()
);
return funcBody([{ count: 1, type: VT_I32 }], body);
}
// Re-export the slot constants for tests that need to introspect memory.
export const WASI_STDIO_FIXTURE_REPLY_SLOTS = REPLY_SLOTS;
export const WASI_STDIO_FIXTURE_STDIN_PTR = STDIN_PTR;

View File

@@ -0,0 +1,112 @@
/**
* Minimal opcode helpers for hand-encoded WASM function bodies. Only the
* subset the bridge's fixture WASM needs is implemented — enough to
* model a bump allocator, a memcpy loop, packed i64 returns, an
* `unreachable` trap, and load/store of static byte ranges out of the
* data section.
*/
import { concat, encodeI32, encodeU32 } from "./leb128.js";
// Value types.
export const VT_I32 = 0x7f;
export const VT_I64 = 0x7e;
// Instructions.
export const OP_UNREACHABLE = 0x00;
export const OP_NOP = 0x01;
export const OP_BLOCK = 0x02;
export const OP_LOOP = 0x03;
export const OP_BR = 0x0c;
export const OP_BR_IF = 0x0d;
export const OP_RETURN = 0x0f;
export const OP_CALL = 0x10;
export const OP_DROP = 0x1a;
export const OP_LOCAL_GET = 0x20;
export const OP_LOCAL_SET = 0x21;
export const OP_LOCAL_TEE = 0x22;
export const OP_GLOBAL_GET = 0x23;
export const OP_GLOBAL_SET = 0x24;
export const OP_I32_LOAD = 0x28;
export const OP_I32_LOAD8_U = 0x2d;
export const OP_I32_STORE = 0x36;
export const OP_I32_STORE8 = 0x3a;
export const OP_I32_CONST = 0x41;
export const OP_I64_CONST = 0x42;
export const OP_I32_EQZ = 0x45;
export const OP_I32_LT_U = 0x49;
export const OP_I32_ADD = 0x6a;
export const OP_I32_SUB = 0x6b;
export const OP_I64_OR = 0x84;
export const OP_I64_SHL = 0x86;
export const OP_I64_EXTEND_I32_U = 0xad;
export const OP_END = 0x0b;
export const BLOCK_TYPE_EMPTY = 0x40;
export function opI32Const(v: number): Uint8Array {
return concat(new Uint8Array([OP_I32_CONST]), encodeI32(v));
}
export function opLocalGet(idx: number): Uint8Array {
return concat(new Uint8Array([OP_LOCAL_GET]), encodeU32(idx));
}
export function opLocalSet(idx: number): Uint8Array {
return concat(new Uint8Array([OP_LOCAL_SET]), encodeU32(idx));
}
export function opLocalTee(idx: number): Uint8Array {
return concat(new Uint8Array([OP_LOCAL_TEE]), encodeU32(idx));
}
export function opGlobalGet(idx: number): Uint8Array {
return concat(new Uint8Array([OP_GLOBAL_GET]), encodeU32(idx));
}
export function opGlobalSet(idx: number): Uint8Array {
return concat(new Uint8Array([OP_GLOBAL_SET]), encodeU32(idx));
}
/** memarg = align (u32) + offset (u32). */
function memarg(align = 0, offset = 0): Uint8Array {
return concat(encodeU32(align), encodeU32(offset));
}
export function opI32Load8U(): Uint8Array {
return concat(new Uint8Array([OP_I32_LOAD8_U]), memarg(0, 0));
}
export function opI32Store8(): Uint8Array {
return concat(new Uint8Array([OP_I32_STORE8]), memarg(0, 0));
}
export function opBr(depth: number): Uint8Array {
return concat(new Uint8Array([OP_BR]), encodeU32(depth));
}
export function opBrIf(depth: number): Uint8Array {
return concat(new Uint8Array([OP_BR_IF]), encodeU32(depth));
}
export function opCall(funcIdx: number): Uint8Array {
return concat(new Uint8Array([OP_CALL]), encodeU32(funcIdx));
}
/** Wrap body bytes as a function body: locals + body + end. */
export function funcBody(
locals: ReadonlyArray<{ readonly count: number; readonly type: number }>,
body: Uint8Array
): Uint8Array {
const localGroups: Uint8Array[] = [];
for (const group of locals) {
localGroups.push(
concat(encodeU32(group.count), new Uint8Array([group.type]))
);
}
const localsHeader = concat(
encodeU32(locals.length),
concat(...localGroups)
);
const inner = concat(localsHeader, body, new Uint8Array([OP_END]));
return concat(encodeU32(inner.length), inner);
}