WasmRuntimeConformanceSuite
Conformance TCK for WasmRuntime — the uniform safety contract every implementation (JVM Chicory, native wasm3, browser WebAssembly API, and any future impl) must enforce against untrusted kernels. Validate an implementation by subclassing this suite and overriding newRuntime:
class MyWasmRuntimeConformanceTest : WasmRuntimeConformanceSuite() {
override fun newRuntime(config: WasmSandboxConfig) = MyWasmRuntime(config)
}Every vector in WasmKernelFixtures is byte-identical on every target, so all implementations are held to exactly the same contract:
Load-time guards — each rejection is a WasmLoadException, never a raw engine error (a non-us.tractat.kuilt.warp.WasmException escaping load would bypass the executor's terminal-error handling and trigger an anti-entropy retry storm on a verified-but-broken kernel — a remotely-triggerable DoS):
a declared import (capability violation);
linear memory with no explicit max (the memory-bomb: unbounded
memory.grow);a declared max or initial size exceeding WasmSandboxConfig.maxMemoryPages — two arms of one rule, and only the max arm is reachable by a spec-valid module (see loadRejectsOversizeInitialWhoseDeclaredMaxIsWithinTheCap for the algebra and for the vector that reaches the other one);
malformed bytes; missing
warp_alloc/warp_runABI exports; no linear memory at all.
Run-time guards — each fault is a WasmExecutionException, never a hang or a raw error:
a CPU-bomb kernel (infinite
loop/br) is terminated near WasmSandboxConfig.executionTimeout with an error naming the exceeded budget, and the timeout must not poison subsequent invocations;a trap (
unreachable) surfaces as an execution failure;memory.growpast the declared max is denied by the engine (the evidence that reject-no-max is a sufficient memory ceiling), while a grow within it succeeds and the bound moves with it (see resultWrittenIntoNewlyGrownMemoryIsReadBack);guest-controlled ABI words (alloc pointer, packed result pointer/length) with the high bit set are bounds-rejected, never sign-wrapped into host-memory access — and so is a window that sets no high bit at all and simply runs off the end (see resultWindowPastMemoryEndIsBounded, which is what separates a real bounds check from a sign check).
Load-phase execution bound — a module's (start) function runs at instantiation, before any ABI call, so the invocation budget alone cannot bound it. The contract is phase-agnostic: whether an impl runs (start) eagerly under a bounded load (surfacing WasmLoadException) or defers instantiation to the first bounded invocation (surfacing WasmExecutionException), a (start) CPU bomb must fail terminally near the budget — never hang the host (see startSectionCpuBombIsBoundedNotHung).
The CPU-bomb vectors burn REAL wall-clock CPU: the sandbox budget is dropped to 250 ms so a non-conforming impl fails fast instead of wedging the test host. The runTest ceilings behind that budget are wedge backstops, not measurements — a real-millisecond ceiling over real CPU work measures the host as much as the code (#1739) — so they are sized with slack and must not be tightened to "fail faster"; the impl's own budget is the fast detector.
Functions
The CPU-bomb defense (#965): a kernel spinning forever on a backward branch — no calls, so nothing yields voluntarily — must be terminated near WasmSandboxConfig.executionTimeout and surface WasmExecutionException naming the exceeded budget, never hang the host. The guest burns real wall-clock CPU, so the runTest timeout cannot pre-empt it; only the impl's own bound can.
Evidence that reject-no-max is a sufficient memory ceiling (#978): the engine must enforce a module's DECLARED max at grow time. The kernel converts a denied memory.grow into unreachable; if the engine did NOT honour the declared max the grow would succeed, no trap would fire, and this test would fail — the signal that load-time rejection alone does not bound that target's memory.
The size-cap rule's boundary, from below: a module declaring memory 16 16 under a 16-page cap is entirely legal and must load and run.
The unified no-max rule: a module declaring linear memory with NO explicit max is rejected at load on EVERY target. It is the only memory ceiling enforceable identically everywhere — the browser cannot clamp a compiled module's limits after the fact — and with it every loaded module carries a bounded, engine-enforced max (see growPastDeclaredMaxTraps).
The import fixture's memory is bounded, so the capability violation is the only possible rejection — the message assertion pins the import guard itself, not another guard firing first.
A module that breaks the no-max rule and the size-cap rule at once is still one clean WasmLoadException — and that is the entire claim, because this vector cannot say which rule rejected it.
The oversize-max arm of the size-cap rule, isolated: bigmem.wat declares memory 1 64, so its max is over the cap while its initial (1 page) and its explicit max satisfy the other two rules. The oversize-max guard is the only thing that can reject it, which is why this property needs no message assertion — structure does the attributing.
The oversize-initial arm, on the only input that can reach it.
A fresh runtime under test, honouring config.
A result window that is in range and past the end — an ordinary positive pointer addressing real linear memory, whose ptr + len leaves it — is bounds-rejected.
A guest that successfully grows linear memory mid-call and writes its result into the new region gets those exact bytes back.
The load-phase CPU bomb: a (start) function spinning forever runs at instantiation — before any ABI call — so the per-invocation budget alone cannot bound it; an impl that instantiates outside its execution budget hangs at load (a remotely-triggerable DoS: kernels arrive from untrusted peers via lazy fetch). The contract is phase-agnostic — an impl may run (start) under a bounded load (JVM, native: a load-time WasmLoadException) or defer instantiation to the first bounded invocation (browser: a run-time WasmExecutionException) — so the vector drives load + first invoke together and accepts either WasmException arm, as long as it terminates near the budget and names it.
A timeout must not poison the runtime: the runaway op times out AGAIN on its next invocation (whatever the impl's recovery mechanism — a cleared interrupt, a re-armed deadline, a respawned worker — it must re-enforce the budget), and a well-behaved op on the SAME runtime still produces correct bytes afterwards.