Initial Commit
This commit is contained in:
182
packages/api-stack/api/api-sandbox-wasm-host-bridge/tests/fixtures/build-wasm.test.ts
vendored
Normal file
182
packages/api-stack/api/api-sandbox-wasm-host-bridge/tests/fixtures/build-wasm.test.ts
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
455
packages/api-stack/api/api-sandbox-wasm-host-bridge/tests/fixtures/build-wasm.ts
vendored
Normal file
455
packages/api-stack/api/api-sandbox-wasm-host-bridge/tests/fixtures/build-wasm.ts
vendored
Normal 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);
|
||||
}
|
||||
69
packages/api-stack/api/api-sandbox-wasm-host-bridge/tests/fixtures/leb128.ts
vendored
Normal file
69
packages/api-stack/api/api-sandbox-wasm-host-bridge/tests/fixtures/leb128.ts
vendored
Normal 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));
|
||||
}
|
||||
482
packages/api-stack/api/api-sandbox-wasm-host-bridge/tests/fixtures/wasi-stdio/build-wasm.ts
vendored
Normal file
482
packages/api-stack/api/api-sandbox-wasm-host-bridge/tests/fixtures/wasi-stdio/build-wasm.ts
vendored
Normal 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;
|
||||
112
packages/api-stack/api/api-sandbox-wasm-host-bridge/tests/fixtures/wasm-opcodes.ts
vendored
Normal file
112
packages/api-stack/api/api-sandbox-wasm-host-bridge/tests/fixtures/wasm-opcodes.ts
vendored
Normal 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);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { createWasmSandbox } from "@suaveplan/api-sandbox-wasm";
|
||||
import type { PluginManifest } from "@suaveplan/api-types/manifest";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SAND_WASM_BRIDGE_CODES,
|
||||
SandboxWasmBridgeError,
|
||||
} from "../../src/error-codes/error-codes.js";
|
||||
import { createSandboxedWasmPluginModule } from "../../src/index.js";
|
||||
import { buildFixtureWasm, WITNESS_OFFSET } from "../fixtures/build-wasm.js";
|
||||
|
||||
const MANIFEST: PluginManifest = {
|
||||
name: "full-flow",
|
||||
version: "0.1.0",
|
||||
apiHost: "@suaveplan/api-host@0",
|
||||
isolation: "sandboxed",
|
||||
trustLevel: "third-party",
|
||||
requiredCapabilities: [],
|
||||
};
|
||||
const ALLOW = { checkCapability: () => ({ allow: true as const }) };
|
||||
|
||||
async function buildInstall(
|
||||
fixture: Parameters<typeof buildFixtureWasm>[0] = {}
|
||||
) {
|
||||
const sandbox = await createWasmSandbox({
|
||||
pluginName: MANIFEST.name,
|
||||
policy: { fs: [], fetch: [], budgets: {} },
|
||||
capabilities: [],
|
||||
checker: ALLOW,
|
||||
wasmBytes: buildFixtureWasm(fixture),
|
||||
});
|
||||
return createSandboxedWasmPluginModule({ sandbox, manifest: MANIFEST });
|
||||
}
|
||||
|
||||
function ctx(method: string, path: string, body?: string): never {
|
||||
const url = `https://app.example.com${path}`;
|
||||
const request =
|
||||
body === undefined
|
||||
? new Request(url, { method })
|
||||
: new Request(url, {
|
||||
method,
|
||||
body,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
return {
|
||||
request,
|
||||
principal: null,
|
||||
tenant: null,
|
||||
capabilities: {},
|
||||
requestId: "rid",
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("full-flow integration", () => {
|
||||
it("WASM-exported routes register with the host", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const routesJson = JSON.stringify([
|
||||
{ method: "GET", path: "/items" },
|
||||
{ method: "POST", path: "/items" },
|
||||
]);
|
||||
const install = await buildInstall({ routesJson });
|
||||
try {
|
||||
expect(install.routes.map((r) => r.method)).toEqual([
|
||||
"GET",
|
||||
"POST",
|
||||
]);
|
||||
expect(install.routes.map((r) => r.path)).toEqual([
|
||||
"/items",
|
||||
"/items",
|
||||
]);
|
||||
expect(install.routes.every((r) => r.plugin === "full-flow")).toBe(
|
||||
true
|
||||
);
|
||||
} finally {
|
||||
install.module.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("1 KiB body round-trips through WASM bridge", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const install = await buildInstall();
|
||||
try {
|
||||
install.module.start();
|
||||
const route = install.routes[0];
|
||||
if (route === undefined) throw new Error("no route");
|
||||
// Build a 1 KiB body of ASCII bytes — easy to assert against.
|
||||
const body = "x".repeat(1024);
|
||||
const response = (await route.handler(
|
||||
ctx("POST", "/hello", body)
|
||||
)) as Response;
|
||||
expect(response.status).toBe(200);
|
||||
// Handler observed the bytes: witness range carries the
|
||||
// marshalled JSON which embeds the base64 body. We verify the
|
||||
// serialised JSON reached the guest by scanning for the URL.
|
||||
const memory = install.sandbox.instance.exports
|
||||
.memory as WebAssembly.Memory;
|
||||
const witness = new TextDecoder().decode(
|
||||
new Uint8Array(memory.buffer, WITNESS_OFFSET, 1024)
|
||||
);
|
||||
expect(witness).toContain('"url":"https://app.example.com/hello"');
|
||||
expect(witness).toContain('"method":"POST"');
|
||||
// Response body reached the caller intact.
|
||||
expect(await response.text()).toBe("ok");
|
||||
} finally {
|
||||
install.module.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("WASM unreachable instruction surfaces HANDLER_TRAPPED", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const install = await buildInstall({ handlerTraps: true });
|
||||
try {
|
||||
install.module.start();
|
||||
const route = install.routes[0];
|
||||
if (route === undefined) throw new Error("no route");
|
||||
await expect(
|
||||
route.handler(ctx("GET", "/hello"))
|
||||
).rejects.toMatchObject({
|
||||
code: SAND_WASM_BRIDGE_CODES.HANDLER_TRAPPED,
|
||||
context: { trapKind: "unreachable" },
|
||||
});
|
||||
} finally {
|
||||
install.module.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("denied capability surfaces SAND_PERM.DENIED through bridge", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const responseJson = JSON.stringify({
|
||||
status: 403,
|
||||
headers: [],
|
||||
bodyBase64: "",
|
||||
errorCode: "SAND_PERM.DENIED",
|
||||
errorMessage: "fs.write denied",
|
||||
});
|
||||
const install = await buildInstall({ responseJson });
|
||||
try {
|
||||
install.module.start();
|
||||
const route = install.routes[0];
|
||||
if (route === undefined) throw new Error("no route");
|
||||
await expect(
|
||||
route.handler(ctx("GET", "/hello"))
|
||||
).rejects.toMatchObject({
|
||||
code: "SAND_PERM.DENIED",
|
||||
});
|
||||
} finally {
|
||||
install.module.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("ABI validator rejects missing __suaveplan_handle_request at bridge-init", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
try {
|
||||
await buildInstall({ dropExports: ["__suaveplan_handle_request"] });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SandboxWasmBridgeError);
|
||||
const e = err as SandboxWasmBridgeError;
|
||||
expect(e.code).toBe(SAND_WASM_BRIDGE_CODES.INVALID_PLUGIN_SURFACE);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,404 @@
|
||||
import { createWasmSandbox } from "@suaveplan/api-sandbox-wasm";
|
||||
import type { PluginManifest } from "@suaveplan/api-types/manifest";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SAND_WASM_BRIDGE_CODES,
|
||||
SandboxWasmBridgeError,
|
||||
} from "../../src/error-codes/error-codes.js";
|
||||
import { createSandboxedWasmPluginModule } from "../../src/index.js";
|
||||
import { buildFixtureWasm } from "../fixtures/build-wasm.js";
|
||||
import { buildWasiStdioFixtureWasm } from "../fixtures/wasi-stdio/build-wasm.js";
|
||||
|
||||
const MANIFEST: PluginManifest = {
|
||||
name: "wasi-stdio-flow",
|
||||
version: "0.1.0",
|
||||
apiHost: "@suaveplan/api-host@0",
|
||||
isolation: "sandboxed",
|
||||
trustLevel: "third-party",
|
||||
requiredCapabilities: [],
|
||||
};
|
||||
const ALLOW = { checkCapability: () => ({ allow: true as const }) };
|
||||
|
||||
async function buildWasiStdioInstall(
|
||||
fixture: Parameters<typeof buildWasiStdioFixtureWasm>[0] = {}
|
||||
) {
|
||||
// Sandbox is built against the OLD direct-variant fixture so the
|
||||
// dispose chain has a real handle to drive. Its `instance` is not
|
||||
// used by the wasi-stdio bridge — the bridge builds its own
|
||||
// instance from `wasmModule` below.
|
||||
const sandbox = await createWasmSandbox({
|
||||
pluginName: MANIFEST.name,
|
||||
policy: { fs: [], fetch: [], budgets: {} },
|
||||
capabilities: [],
|
||||
checker: ALLOW,
|
||||
wasmBytes: buildFixtureWasm({}),
|
||||
});
|
||||
const wasmModule = await WebAssembly.compile(
|
||||
buildWasiStdioFixtureWasm(fixture)
|
||||
);
|
||||
return createSandboxedWasmPluginModule({
|
||||
abiVariant: "wasi-stdio",
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
wasmModule,
|
||||
});
|
||||
}
|
||||
|
||||
function ctx(method: string, path: string, body?: string): never {
|
||||
const url = `https://app.example.com${path}`;
|
||||
const request =
|
||||
body === undefined
|
||||
? new Request(url, { method })
|
||||
: new Request(url, {
|
||||
method,
|
||||
body,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
return {
|
||||
request,
|
||||
principal: null,
|
||||
tenant: null,
|
||||
capabilities: {},
|
||||
requestId: "rid",
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("wasi-stdio flow integration", () => {
|
||||
it("route discovery via envelope", { timeout: 30000 }, async () => {
|
||||
const install = await buildWasiStdioInstall();
|
||||
try {
|
||||
expect(install.routes.map((r) => r.method)).toEqual([
|
||||
"GET",
|
||||
"POST",
|
||||
]);
|
||||
expect(install.routes.map((r) => r.path)).toEqual([
|
||||
"/hello",
|
||||
"/echo",
|
||||
]);
|
||||
expect(
|
||||
install.routes.every((r) => r.plugin === MANIFEST.name)
|
||||
).toBe(true);
|
||||
} finally {
|
||||
install.module.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("1 KiB body round-trips through WASI-stdio", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const install = await buildWasiStdioInstall();
|
||||
try {
|
||||
install.module.start();
|
||||
const route = install.routes[0];
|
||||
if (route === undefined) throw new Error("no route");
|
||||
const body = "x".repeat(1024);
|
||||
const response = (await route.handler(
|
||||
ctx("POST", "/hello", body)
|
||||
)) as Response;
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("ok");
|
||||
} finally {
|
||||
install.module.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("WASI-stdio unreachable mid-request surfaces HANDLER_TRAPPED", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const install = await buildWasiStdioInstall({ trapOnCall: 2 });
|
||||
try {
|
||||
install.module.start();
|
||||
const route = install.routes[0];
|
||||
if (route === undefined) throw new Error("no route");
|
||||
await expect(
|
||||
route.handler(ctx("GET", "/hello"))
|
||||
).rejects.toMatchObject({
|
||||
code: SAND_WASM_BRIDGE_CODES.HANDLER_TRAPPED,
|
||||
context: { trapKind: "unreachable" },
|
||||
});
|
||||
} finally {
|
||||
install.module.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("denied capability under WASI-stdio surfaces SAND_PERM.DENIED", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const install = await buildWasiStdioInstall({
|
||||
requestReply: JSON.stringify({
|
||||
kind: "error",
|
||||
code: "SAND_PERM.DENIED",
|
||||
message: "fs.write denied",
|
||||
}),
|
||||
});
|
||||
try {
|
||||
install.module.start();
|
||||
const route = install.routes[0];
|
||||
if (route === undefined) throw new Error("no route");
|
||||
await expect(
|
||||
route.handler(ctx("GET", "/hello"))
|
||||
).rejects.toMatchObject({
|
||||
code: "SAND_PERM.DENIED",
|
||||
});
|
||||
} finally {
|
||||
install.module.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("non-SAND_PERM plugin error surfaces CAPABILITY_DENIED via WASI-stdio", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const install = await buildWasiStdioInstall({
|
||||
requestReply: JSON.stringify({
|
||||
kind: "error",
|
||||
code: "PLUGIN.RATE_LIMITED",
|
||||
message: "too many",
|
||||
}),
|
||||
});
|
||||
try {
|
||||
install.module.start();
|
||||
const route = install.routes[0];
|
||||
if (route === undefined) throw new Error("no route");
|
||||
await expect(
|
||||
route.handler(ctx("GET", "/hello"))
|
||||
).rejects.toMatchObject({
|
||||
code: SAND_WASM_BRIDGE_CODES.CAPABILITY_DENIED,
|
||||
});
|
||||
} finally {
|
||||
install.module.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("WASI-stdio ABI validator rejects missing _start", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const sandbox = await createWasmSandbox({
|
||||
pluginName: MANIFEST.name,
|
||||
policy: { fs: [], fetch: [], budgets: {} },
|
||||
capabilities: [],
|
||||
checker: ALLOW,
|
||||
wasmBytes: buildFixtureWasm({}),
|
||||
});
|
||||
const wasmModule = await WebAssembly.compile(
|
||||
buildWasiStdioFixtureWasm({ dropExports: ["_start"] })
|
||||
);
|
||||
try {
|
||||
createSandboxedWasmPluginModule({
|
||||
abiVariant: "wasi-stdio",
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
wasmModule,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SandboxWasmBridgeError);
|
||||
const e = err as SandboxWasmBridgeError;
|
||||
expect(e.code).toBe(SAND_WASM_BRIDGE_CODES.INVALID_PLUGIN_SURFACE);
|
||||
const ctxMissing = (e.context as { missing?: readonly string[] })
|
||||
.missing;
|
||||
expect(ctxMissing).toContain("_start");
|
||||
} finally {
|
||||
sandbox.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("WASI-stdio ABI validator rejects missing fd_write import", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const sandbox = await createWasmSandbox({
|
||||
pluginName: MANIFEST.name,
|
||||
policy: { fs: [], fetch: [], budgets: {} },
|
||||
capabilities: [],
|
||||
checker: ALLOW,
|
||||
wasmBytes: buildFixtureWasm({}),
|
||||
});
|
||||
const wasmModule = await WebAssembly.compile(
|
||||
buildWasiStdioFixtureWasm({ dropImports: ["fd_write"] })
|
||||
);
|
||||
try {
|
||||
createSandboxedWasmPluginModule({
|
||||
abiVariant: "wasi-stdio",
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
wasmModule,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
const e = err as SandboxWasmBridgeError;
|
||||
expect(e.code).toBe(SAND_WASM_BRIDGE_CODES.INVALID_PLUGIN_SURFACE);
|
||||
const ctxMissing = (e.context as { missing?: readonly string[] })
|
||||
.missing;
|
||||
expect(ctxMissing).toContain("wasi_snapshot_preview1.fd_write");
|
||||
} finally {
|
||||
sandbox.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("dispose twice exchanges only one envelope", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Configure the fixture so the dispose slot lines up with the
|
||||
// call counter even when no request is sent. With no request,
|
||||
// dispose lands at call counter 2, so we steer slot 2's reply
|
||||
// to `{kind:"ok"}` instead of the default request reply.
|
||||
const install = await buildWasiStdioInstall({
|
||||
requestReply: JSON.stringify({ kind: "ok" }),
|
||||
});
|
||||
install.module.start();
|
||||
install.module.stop();
|
||||
install.module.stop();
|
||||
expect(install.lifecycle.disposed()).toBe(true);
|
||||
expect(install.sandbox.disposed).toBe(true);
|
||||
});
|
||||
|
||||
it("start twice runs once (idempotent)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const install = await buildWasiStdioInstall({
|
||||
requestReply: JSON.stringify({ kind: "ok" }),
|
||||
});
|
||||
try {
|
||||
install.module.start();
|
||||
install.module.start();
|
||||
expect(install.lifecycle.started()).toBe(true);
|
||||
} finally {
|
||||
install.module.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("malformed reply bytes from guest surface PROTOCOL_MISMATCH (decode failure)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Force the guest to emit a non-JSON byte sequence in the
|
||||
// request slot. The runner's decoder rejects with
|
||||
// PROTOCOL_MISMATCH (reason=invalid_json), and the bridge's
|
||||
// route handler routes the throw through the `marshal_failure`
|
||||
// instrument branch (HANDLER_TRAPPED is reserved for WASM
|
||||
// traps, not decoder failures).
|
||||
const install = await buildWasiStdioInstall({
|
||||
requestReplyRaw: new TextEncoder().encode("not json at all"),
|
||||
});
|
||||
try {
|
||||
install.module.start();
|
||||
const route = install.routes[0];
|
||||
if (route === undefined) throw new Error("no route");
|
||||
await expect(
|
||||
route.handler(ctx("GET", "/hello"))
|
||||
).rejects.toMatchObject({
|
||||
code: SAND_WASM_BRIDGE_CODES.PROTOCOL_MISMATCH,
|
||||
});
|
||||
} finally {
|
||||
install.sandbox.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("request reply with `ok` kind surfaces PROTOCOL_MISMATCH", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const install = await buildWasiStdioInstall({
|
||||
requestReply: JSON.stringify({ kind: "ok" }),
|
||||
});
|
||||
try {
|
||||
install.module.start();
|
||||
const route = install.routes[0];
|
||||
if (route === undefined) throw new Error("no route");
|
||||
await expect(
|
||||
route.handler(ctx("GET", "/hello"))
|
||||
).rejects.toMatchObject({
|
||||
code: SAND_WASM_BRIDGE_CODES.PROTOCOL_MISMATCH,
|
||||
context: { expected: "response", received: "ok" },
|
||||
});
|
||||
} finally {
|
||||
install.sandbox.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("bridge accepts logger + stdioTimeoutMs + extraImports options", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const sandbox = await createWasmSandbox({
|
||||
pluginName: MANIFEST.name,
|
||||
policy: { fs: [], fetch: [], budgets: {} },
|
||||
capabilities: [],
|
||||
checker: ALLOW,
|
||||
wasmBytes: buildFixtureWasm({}),
|
||||
});
|
||||
const wasmModule = await WebAssembly.compile(
|
||||
buildWasiStdioFixtureWasm({
|
||||
requestReply: JSON.stringify({ kind: "ok" }),
|
||||
})
|
||||
);
|
||||
const lines: string[] = [];
|
||||
const install = createSandboxedWasmPluginModule({
|
||||
abiVariant: "wasi-stdio",
|
||||
sandbox,
|
||||
manifest: MANIFEST,
|
||||
wasmModule,
|
||||
logger: { warn: (line) => lines.push(line) },
|
||||
stdioTimeoutMs: 9999,
|
||||
extraImports: { foo_namespace: {} },
|
||||
});
|
||||
try {
|
||||
install.module.start();
|
||||
} finally {
|
||||
install.module.stop();
|
||||
}
|
||||
expect(install.lifecycle.disposed()).toBe(true);
|
||||
});
|
||||
|
||||
it("missing requiredCapabilities falls back to empty array", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const sandbox = await createWasmSandbox({
|
||||
pluginName: MANIFEST.name,
|
||||
policy: { fs: [], fetch: [], budgets: {} },
|
||||
capabilities: [],
|
||||
checker: ALLOW,
|
||||
wasmBytes: buildFixtureWasm({}),
|
||||
});
|
||||
const wasmModule = await WebAssembly.compile(
|
||||
buildWasiStdioFixtureWasm({})
|
||||
);
|
||||
const manifestNoCap = { ...MANIFEST };
|
||||
delete (manifestNoCap as { requiredCapabilities?: readonly string[] })
|
||||
.requiredCapabilities;
|
||||
const install = createSandboxedWasmPluginModule({
|
||||
abiVariant: "wasi-stdio",
|
||||
sandbox,
|
||||
manifest: manifestNoCap as never,
|
||||
wasmModule,
|
||||
});
|
||||
expect(install.module.requires).toEqual([]);
|
||||
install.sandbox.dispose();
|
||||
});
|
||||
|
||||
it("install.lifecycle adapter exposes start/dispose handles", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const install = await buildWasiStdioInstall({
|
||||
requestReply: JSON.stringify({ kind: "ok" }),
|
||||
});
|
||||
install.lifecycle.start();
|
||||
expect(install.lifecycle.started()).toBe(true);
|
||||
install.lifecycle.dispose();
|
||||
expect(install.lifecycle.disposed()).toBe(true);
|
||||
});
|
||||
|
||||
it("protocol mismatch — start replied with routes surfaces LIFECYCLE_FAILED", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const install = await buildWasiStdioInstall({
|
||||
startReply: JSON.stringify({ kind: "routes", routes: [] }),
|
||||
});
|
||||
try {
|
||||
expect(() => install.module.start()).toThrow();
|
||||
} catch {
|
||||
// ignored
|
||||
} finally {
|
||||
// Skip stop() — the start failed mid-exchange and dispose
|
||||
// would land on the wrong fixture slot; we already proved
|
||||
// idempotency in the other tests.
|
||||
install.sandbox.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user