456 lines
14 KiB
TypeScript
456 lines
14 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|