open-standards/wai/wai-module-check/planted/build.py
Transaction Science a2e11f6ff2 waism: the probe of §9.3 and its vectors, and the probe with a NaN-dependent branch planted
planted/build.py now writes §9.3's probe beside the checker corpus: probe.wasm, whose wai_run
loads each case's operands from a data segment at run time, runs the twelve operations of the
table and stores each result in order (80 bytes), in a memory of one page whose minimum is its
maximum; and probe.txt, each case's operands and the result the deterministic profile
prescribes, and the probe's bytes. probe-nan-branch.wasm is the probe with a branch after each
operation on whether its result is a NaN, writing the canonical NaN itself.

Both checkers hold both at the float level and refuse them R3 at the integer level
(cases.txt; tests/module_rules.rs). What the planted copy breaks is F1, a property of the probe
on engines that no checker decides; the next commit's host shows the probe holds it and the
planted copy fails it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:37:15 -05:00

924 lines
36 KiB
Python

#!/usr/bin/env python3
"""build.py: the checker corpus of the WAISM profile, built from source.
wai/extensions/waism.md §9.1 gives each module of the checker corpus by its
construction: `minimal-int` and, for every other case but `component`, `minimal-int` with one
change. This script is that construction, byte by byte. It writes each module as
`<case>.wasm` beside itself, and `cases.txt`, the verdicts §9.1 states for them.
A verdict's offset is taken from the construction, never from a checker: each change marks
the item its row of §9.1 names (an instruction, a value type, an entry, a section's id byte)
as the bytes are put together, and the offset in `cases.txt` is where that mark lands in the
module. `at the module's length` is the module's length.
`cases.txt` has one line a verdict: `<case> <level> <caps> <verdict>`, where `<level>` is
`integer` or `float`, `<caps>` is `-` for the profile's caps (§4.2) or the caps the row
states, comma-joined `name=value`, and `<verdict>` is §4.3's line. A row of §9.1 at both
levels gives a line at each. Lines §9.1 does not state but §4.3 decides are marked
`# derived` after the verdict: the other level of a row that breaks only R3, and the
profile's caps for the rows that state lower ones. `grow-swallowed`, §9.2's planted module,
is here too: it meets R1 to R12, and R13, which it breaks, is no checker's rule.
§9.3's probe is here as well, `probe.wasm`, held at the float level, with `probe.txt`: each
case's operands and the result the deterministic profile prescribes, and the probe's bytes,
the whole output its `wai_run` gives on an engine set to that profile's NaN rule. Beside it,
`probe-nan-branch.wasm` is the probe with a branch on whether each result is a NaN that
writes the canonical NaN itself: it meets R1 to R12 too, and gives the probe's bytes on every
engine, so it cannot tell an engine set to the NaN rule from one that is not. That is F1, a
property of the probe on engines, which no checker decides; `../wai-det-jpegai/host --f1`
shows the probe holds it and the planted copy fails it.
python3 build.py write the modules, cases.txt and probe.txt
python3 build.py --check exit 1 unless the files here are exactly what it would write
python3 build.py --limits DIR write the validation limits' corpus to DIR (below)
The validation limits of §4.2 are part of R2. `--limits` writes, for each, `minimal-int`
with the part it bounds at the limit and one past it, and DIR/cases.txt in the same form:
at the limit the verdict is that of the rule the part breaks, if any (R12 for a tag section,
R10 for an extra export, held for unused globals); one past it, `refused R2 -`. These are
large modules (up to 10 MB), so they are generated where they are checked and are not
committed.
Standard library only.
"""
import os
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
# ------------------------------------------------------------------ bytes with marks
class Blob:
"""Bytes, with named offsets into them that follow the bytes when they are joined."""
def __init__(self, data=b"", marks=None):
self.data = bytes(data)
self.marks = dict(marks or {})
def __add__(self, other):
marks = dict(self.marks)
for k, v in other.marks.items():
assert k not in marks, k
marks[k] = v + len(self.data)
return Blob(self.data + other.data, marks)
def __len__(self):
return len(self.data)
def b(*xs):
return Blob(bytes(xs))
def cat(*blobs):
data, marks, at = [], {}, 0
for x in blobs:
for k, v in x.marks.items():
assert k not in marks, k
marks[k] = v + at
data.append(x.data)
at += len(x.data)
return Blob(b"".join(data), marks)
def mark(name, blob):
assert name not in blob.marks
return Blob(blob.data, {name: 0, **blob.marks})
def uleb(n):
out = bytearray()
while True:
c = n & 0x7F
n >>= 7
out.append(c | (0x80 if n else 0))
if not n:
return Blob(out)
def sleb(n):
out = bytearray()
while True:
c = n & 0x7F
n >>= 7
done = (n == 0 and not c & 0x40) or (n == -1 and c & 0x40)
out.append(c | (0 if done else 0x80))
if done:
return Blob(out)
def vec(items):
return uleb(len(items)) + cat(*items)
def name(s):
raw = s.encode("utf-8")
return uleb(len(raw)) + Blob(raw)
def sized(blob):
return uleb(len(blob)) + blob
# ------------------------------------------------------------------ encodings
I32, I64, F32, F64, V128 = 0x7F, 0x7E, 0x7D, 0x7C, 0x7B
FUNCREF, EXTERNREF = 0x70, 0x6F
END, DROP = 0x0B, 0x1A
def functype(params, results, item_param=None):
"""A function type; with `item_param`, the value type of that parameter is marked `item`."""
ps = [mark("item", b(t)) if i == item_param else b(t) for i, t in enumerate(params)]
return b(0x60) + vec(ps) + vec([b(t) for t in results])
def limits(minimum, maximum=None, i64=False):
flag = (0x04 if i64 else 0) | (0x01 if maximum is not None else 0)
out = b(flag) + uleb(minimum)
return out + uleb(maximum) if maximum is not None else out
def i32_const(n):
return b(0x41) + sleb(n)
def i64_const(n):
return b(0x42) + sleb(n)
def f32_const(x):
return b(0x43) + Blob(struct.pack("<f", x))
def f64_const(x):
return b(0x44) + Blob(struct.pack("<d", x))
def v128_const(lanes=bytes(16)):
return b(0xFD) + uleb(0x0C) + Blob(lanes)
def simd(op):
return b(0xFD) + uleb(op)
def memarg(align, offset=0):
return uleb(align) + uleb(offset)
def export(nm, kind, index):
return name(nm) + b(kind) + uleb(index)
def body(instrs, locals_=None):
"""A function body: its local declarations (none by default), then its instructions,
which end with `end`."""
return sized((locals_ if locals_ is not None else uleb(0)) + instrs)
def section(sid, content, tag=None):
head = b(sid)
if tag:
head = mark(tag, head)
return head + sized(content)
# ------------------------------------------------------------------ minimal-int
T_ABI, T_ALLOC, T_RUN = 0, 1, 2 # () -> i32, (i32) -> i32, (i32 x 6) -> i32
EXPORTS = ["memory", "wai_abi", "wai_alloc", "wai_run", "wai_out_ptr", "wai_out_len"]
def store_and_return(addr=None):
"""wai_run's own instructions: store one fixed byte at offset 0, return 0."""
return cat(addr or i32_const(0), i32_const(0x2A), b(0x3A), memarg(0), i32_const(0), b(END))
class Module:
"""minimal-int, as §9.1 states it, with every part open to a change."""
def __init__(self):
self.types = [functype([], [I32]), functype([I32], [I32]), functype([I32] * 6, [I32])]
self.imports = []
self.funcs = [T_ABI, T_ALLOC, T_RUN, T_ABI, T_ABI]
self.tables = []
self.memories = [limits(1, 2)]
self.tags = None
self.globals = []
self.exports = None # the six of §5 unless set
self.func_base = 0 # the index of the first defined function (after imports)
self.start = None
self.elems = None
self.run_prefix = Blob() # added to wai_run before its store
self.run_locals = None
self.run_rest = None # wai_run's instructions after the prefix, if changed
self.abi_body = cat(i32_const(1), b(END))
self.alloc_body = cat(i32_const(0), b(END))
self.out_len_body = cat(i32_const(1), b(END))
self.extra_bodies = [] # bodies of functions after the five
self.customs = []
self.datas = None
self.no_memory_export = False
self.drop_exports = set()
def default_exports(self):
out = []
for i, nm in enumerate(EXPORTS):
if nm in self.drop_exports or (nm == "memory" and self.no_memory_export):
continue
out.append(export(nm, 0x02, 0) if nm == "memory" else export(nm, 0x00, self.func_base + i - 1))
return out
def build(self):
exports = self.exports if self.exports is not None else self.default_exports()
run = self.run_prefix + (self.run_rest if self.run_rest is not None else store_and_return())
bodies = [
body(self.abi_body),
body(self.alloc_body),
body(run, self.run_locals),
body(cat(i32_const(0), b(END))),
body(self.out_len_body),
] + self.extra_bodies
parts = [Blob(b"\0asm") + Blob(struct.pack("<I", 1))]
parts.append(section(1, vec(self.types)))
if self.imports:
parts.append(section(2, vec(self.imports)))
parts.append(section(3, vec([uleb(t) for t in self.funcs]), "function-section"))
if self.tables:
parts.append(section(4, vec(self.tables)))
if self.memories:
parts.append(section(5, vec(self.memories)))
if self.tags is not None:
parts.append(section(13, vec(self.tags), "tag-section"))
if self.globals:
parts.append(section(6, vec(self.globals)))
parts.append(section(7, vec(exports)))
if self.start is not None:
parts.append(section(8, uleb(self.start), "start-section"))
if self.elems is not None:
parts.append(section(9, vec(self.elems)))
parts.append(section(10, vec(bodies)))
if self.datas is not None:
parts.append(section(11, vec(self.datas)))
for c in self.customs:
parts.append(section(0, c))
return cat(*parts)
# ------------------------------------------------------------------ the cases
def case_table():
"""(case, module blob, [(level, caps, verdict-spec, derived)]) for every case.
A verdict-spec is `held`, `R1`, `R2`, or (rule, mark) where mark names the item, or
`end` for the module's length."""
cases = []
BOTH = ("integer", "float")
def add(nm, blob, rows):
cases.append((nm, blob, rows))
def both(spec, caps="-"):
return [(lvl, caps, spec, False) for lvl in BOTH]
def int_only(spec):
return [("integer", "-", spec, False), ("float", "-", "held", True)]
m = Module()
minimal = m.build()
# -- planted
add("oversize", minimal, both("R1", f"module_bytes={len(minimal) - 1}")
+ [(lvl, "-", "held", True) for lvl in BOTH])
m = Module()
m.abi_body = cat(i64_const(1), b(END))
add("invalid", m.build(), both("R2"))
m = Module()
m.memories = [b(0x03) + uleb(1) + uleb(2)] # shared, with a maximum
m.run_prefix = cat(i32_const(0), b(0xFE) + uleb(0x10) + memarg(2), b(DROP)) # i32.atomic.load
add("shared-memory", m.build(), both("R2"))
add("component", Blob(b"\0asm") + Blob(bytes([0x0D, 0x00, 0x01, 0x00])), both("R2"))
m = Module()
m.run_prefix = cat(mark("item", f32_const(1.0)), f32_const(2.0), b(0x92), b(DROP))
add("scalar-float", m.build(), int_only(("R3", "item")))
m = Module()
m.run_prefix = cat(i32_const(0), b(0x0F), mark("item", b(0x92)), b(DROP))
add("float-unreachable", m.build(), int_only(("R3", "item")))
m = Module()
# block (result f32) unreachable end, its result dropped: the item is the block type.
m.run_prefix = cat(b(0x02), mark("item", b(F32)), b(0x00), b(END), b(DROP))
add("float-block-type", m.build(), int_only(("R3", "item")))
m = Module()
# return, then select (result f32) of a polymorphic stack: the item is its value type.
m.run_prefix = cat(i32_const(0), b(0x0F), i32_const(0), b(0x1C) + uleb(1), mark("item", b(F32)), b(DROP))
add("float-select-type", m.build(), int_only(("R3", "item")))
m = Module()
m.globals = [mark("item", b(F64)) + b(0x00) + f64_const(0.0) + b(END)]
add("float-constant", m.build(), int_only(("R3", "item")))
m = Module()
m.types.append(functype([F64], [], item_param=0))
add("float-signature", m.build(), int_only(("R3", "item")))
m = Module()
m.run_prefix = cat(v128_const(), v128_const(), mark("item", simd(0xE4)), b(DROP)) # f32x4.add
add("float-vector", m.build(), int_only(("R3", "item")))
m = Module()
m.run_prefix = cat(v128_const(), mark("item", simd(0xFA)), b(DROP)) # f32x4.convert_i32x4_s
add("float-conversion", m.build(), int_only(("R3", "item")))
m = Module()
# i32.trunc_sat_f32_s of f32.reinterpret_i32
m.run_prefix = cat(i32_const(0), mark("item", b(0xBE)), b(0xFC) + uleb(0), b(DROP))
add("float-truncation", m.build(), int_only(("R3", "item")))
m = Module()
m.run_prefix = cat(i32_const(0), mark("item", b(0x2A) + memarg(2)), b(DROP)) # f32.load
add("float-load", m.build(), int_only(("R3", "item")))
for nm, op, arity in [("relaxed-swizzle", 0x100, 2), ("relaxed-laneselect", 0x10A, 3),
("relaxed-dot", 0x112, 2)]:
m = Module()
m.run_prefix = cat(*[v128_const()] * arity, mark("item", simd(op)), b(DROP))
add(nm, m.build(), both(("R4", "item")))
m = Module()
m.run_prefix = cat(*[v128_const()] * 3, mark("item", simd(0x105)), b(DROP)) # f32x4.relaxed_madd
add("relaxed-madd", m.build(), [("float", "-", ("R4", "item"), False),
("integer", "-", ("R3", "item"), True)])
m = Module()
m.imports = [mark("item", name("env") + name("clock") + b(0x00) + uleb(T_ABI))]
m.func_base = 1
add("import-function", m.build(), both(("R5", "item")))
m = Module()
m.imports = [mark("item", name("env") + name("memory") + b(0x02) + limits(1, 2))]
m.memories = []
add("import-memory", m.build(), both(("R5", "item")))
m = Module()
m.imports = [mark("item", name("env") + name("g") + b(0x03) + b(I32, 0x00))]
add("import-global", m.build(), both(("R5", "item")))
m = Module()
m.memories = []
m.no_memory_export = True
m.run_rest = cat(i32_const(0), b(END))
add("no-memory", m.build(), both(("R6", "end")))
m = Module()
m.memories = [limits(1, 2), mark("item", limits(1, 2))]
add("two-memories", m.build(), both(("R6", "item")))
m = Module()
m.memories = [mark("item", limits(1, 2, i64=True))]
m.run_rest = store_and_return(addr=i64_const(0))
add("memory64", m.build(), both(("R6", "item")))
m = Module()
m.memories = [mark("item", limits(1))]
add("no-maximum", m.build(), both(("R6", "item")))
m = Module()
m.memories = [mark("item", limits(1, 17))]
add("over-pages", m.build(), both(("R6", "item"), "memory_pages=16")
+ [(lvl, "-", "held", True) for lvl in BOTH])
def fixed_table():
return b(FUNCREF) + limits(0, 0)
m = Module()
m.tables = [fixed_table(), mark("item", fixed_table())]
add("two-tables", m.build(), both(("R7", "item")))
m = Module()
m.tables = [mark("item", b(EXTERNREF) + limits(0, 0))]
add("externref-table", m.build(), both(("R7", "item")))
m = Module()
m.tables = [mark("item", b(FUNCREF) + limits(0, 1))]
add("table-unfixed", m.build(), both(("R7", "item")))
ref_null_func = b(0xD0, FUNCREF)
for nm, prefix in [
("table-grow", cat(ref_null_func, i32_const(0), mark("item", b(0xFC) + uleb(15) + uleb(0)), b(DROP))),
("table-set", cat(i32_const(0), ref_null_func, mark("item", b(0x26) + uleb(0)))),
("table-fill", cat(i32_const(0), ref_null_func, i32_const(0), mark("item", b(0xFC) + uleb(17) + uleb(0)))),
]:
m = Module()
m.tables = [fixed_table()]
m.run_prefix = prefix
add(nm, m.build(), both(("R7", "item")))
m = Module()
m.types.append(functype([], []))
m.funcs.append(3)
m.extra_bodies = [body(b(END))]
m.start = 5
add("start", m.build(), both(("R8", "start-section")))
m = Module()
m.types.append(functype([FUNCREF], [], item_param=0))
m.funcs.append(3)
m.extra_bodies = [body(b(END))]
add("funcref-parameter", m.build(), both(("R9", "item")))
m = Module()
m.globals = [mark("item", b(EXTERNREF)) + b(0x00) + b(0xD0, EXTERNREF) + b(END)]
add("externref-global", m.build(), both(("R9", "item")))
m = Module()
m.run_locals = uleb(1) + uleb(1) + mark("item", b(FUNCREF))
add("funcref-local", m.build(), both(("R9", "item")))
m = Module()
m.types[T_RUN] = functype([I32] * 5 + [I64], [I32])
ex = m.default_exports()
ex[3] = mark("item", ex[3])
m.exports = ex
add("i64-export", m.build(), both(("R10", "item")))
m = Module()
m.exports = m.default_exports() + [mark("item", export("wai_extra", 0x00, 0))]
add("extra-export", m.build(), both(("R10", "item")))
m = Module()
m.globals = [b(I32, 0x00) + i32_const(0) + b(END)]
m.exports = m.default_exports() + [mark("item", export("wai_global", 0x03, 0))]
add("exported-global", m.build(), both(("R10", "item")))
m = Module()
m.drop_exports = {"wai_run"}
add("missing-run", m.build(), both(("R10", "end")))
m = Module()
m.funcs += [T_ABI] * 4
m.extra_bodies = [body(cat(i32_const(0), b(END)))] * 4
add("over-functions", m.build(), both(("R11", "function-section"), "functions=8")
+ [(lvl, "-", "held", True) for lvl in BOTH])
m = Module()
m.run_prefix = mark("item", b(0x12) + uleb(0)) # return_call wai_abi
add("tail-call", m.build(), both(("R12", "item")))
m = Module()
m.types.append(functype([], []))
m.tags = [b(0x00) + uleb(3)]
m.run_prefix = b(0x08) + uleb(0) # throw 0
add("exception", m.build(), both(("R12", "tag-section")))
m = Module()
m.types.append(mark("item", b(0x5F) + uleb(0))) # a struct type, no fields
add("struct-type", m.build(), both(("R12", "item")))
m = Module()
m.elems = [uleb(3) + b(0x00) + vec([uleb(0)])] # declarative: wai_abi
m.run_prefix = cat(b(0xD2) + uleb(0), mark("item", b(0x14) + uleb(T_ABI)), b(DROP))
add("call-ref", m.build(), both(("R12", "item")))
# -- conforming
add("minimal-int", minimal, both("held"))
m = Module()
m.run_prefix = cat(
v128_const(), v128_const(), simd(0xBA), b(DROP), # i32x4.dot_i16x8_s
v128_const(), v128_const(), simd(0x0E), b(DROP), # i8x16.swizzle
v128_const(), v128_const(), simd(0x0D) + Blob(bytes(range(16))), b(DROP), # i8x16.shuffle
v128_const(), v128_const(), simd(0x82), b(DROP), # i16x8.q15mulr_sat_s
)
add("vector-int", m.build(), both("held"))
m = Module()
m.types.append(functype([], [I32, I32]))
m.run_prefix = cat(
i32_const(0), i32_const(0), i32_const(0), b(0xFC) + uleb(10) + uleb(0) + uleb(0), # memory.copy
i32_const(0), i32_const(0), i32_const(0), b(0xFC) + uleb(11) + uleb(0), # memory.fill
i32_const(0), b(0xC0), b(DROP), # i32.extend8_s
b(0x02) + sleb(3), i32_const(1), i32_const(2), b(END), b(DROP), b(DROP), # a block of two results
)
add("bulk-and-sign", m.build(), both("held"))
m = Module()
m.tables = [b(FUNCREF) + limits(1, 1)]
m.elems = [uleb(0) + i32_const(0) + b(END) + vec([uleb(0)])]
m.run_prefix = cat(i32_const(0), b(0x11) + uleb(T_ABI) + uleb(0), b(DROP)) # call_indirect
add("fixed-table", m.build(), both("held"))
m = Module()
m.memories = [limits(1, 1)]
add("fixed-memory", m.build(), both("held"))
m = Module()
m.customs = [name("profile") + Blob(b"waism/1-det")]
add("custom-section", m.build(), both("held"))
m = Module()
m.run_prefix = cat(mark("item", f64_const(1.0)), f64_const(2.0), b(0xA0), b(DROP))
add("minimal-float", m.build(), [("float", "-", "held", False), ("integer", "-", ("R3", "item"), False)])
# -- §9.2: breaks R13 only, which no checker decides
m = Module()
m.run_prefix = cat(i32_const(1), b(0x40) + uleb(0), b(DROP)) # memory.grow 1, result dropped
add("grow-swallowed", m.build(), both("held"))
# -- §9.3: the probe, held at the float level; and the probe with a NaN-dependent
# branch planted, which meets R1 to R12 too and fails F1 on an engine (no checker's rule)
for nm, planted in [("probe", False), ("probe-nan-branch", True)]:
blob, _ = probe_module(planted)
add(nm, blob, [("float", "-", "held", False), ("integer", "-", ("R3", "item"), True)])
return cases
# ------------------------------------------------------------------ §9.3: the probe
CANONICAL = {"f32": 0x7FC00000, "f64": 0x7FF8000000000000}
# Each case of §9.3's table: its number, its operation, the type of its operands, and the
# operands as bits (a vector operand as its lanes, lane 0 first).
PROBE = [
(1, "f32.add", "f32", [0xFFA00001, 0x3F800000]),
(2, "f32.mul", "f32", [0xFFA00001, 0x40000000]),
(3, "f32.div", "f32", [0x00000000, 0x00000000]),
(4, "f32.sub", "f32", [0x7F800000, 0x7F800000]),
(5, "f32.sqrt", "f32", [0xBF800000]),
(6, "f32.min", "f32", [0xFFA00001, 0x3F800000]),
(7, "f32.nearest", "f32", [0xFFA00001]),
(8, "f32.demote_f64", "f64", [0xFFF4000000000001]),
(9, "f64.add", "f64", [0xFFF4000000000001, 0x3FF0000000000000]),
(10, "f64.promote_f32", "f32", [0xFFA00001]),
(11, "f32x4.add", "f32x4", [(0xFFA00001, 0x7FA00001, 0xFFC00000, 0x7F800000),
(0x3F800000, 0x3F800000, 0x3F800000, 0xFF800000)]),
(12, "f64x2.mul", "f64x2", [(0xFFF4000000000001, 0x7FF8000000000001),
(0x4000000000000000, 0x4000000000000000)]),
]
# The result type of each operation, and its opcode.
PROBE_OPS = {
"f32.add": ("f32", lambda: b(0x92)), "f32.mul": ("f32", lambda: b(0x94)),
"f32.div": ("f32", lambda: b(0x95)), "f32.sub": ("f32", lambda: b(0x93)),
"f32.sqrt": ("f32", lambda: b(0x91)), "f32.min": ("f32", lambda: b(0x96)),
"f32.nearest": ("f32", lambda: b(0x90)), "f32.demote_f64": ("f32", lambda: b(0xB6)),
"f64.add": ("f64", lambda: b(0xA0)), "f64.promote_f32": ("f64", lambda: b(0xBB)),
"f32x4.add": ("f32x4", lambda: simd(0xE4)), "f64x2.mul": ("f64x2", lambda: simd(0xF2)),
}
# A type's size, and its load and store (natural alignment).
PROBE_TYPES = {
"f32": (4, lambda: b(0x2A) + memarg(2), lambda: b(0x38) + memarg(2)),
"f64": (8, lambda: b(0x2B) + memarg(3), lambda: b(0x39) + memarg(3)),
"f32x4": (16, lambda: simd(0x00) + memarg(4), lambda: simd(0x0B) + memarg(4)),
"f64x2": (16, lambda: simd(0x00) + memarg(4), lambda: simd(0x0B) + memarg(4)),
}
PROBE_DATA = 256 # where the operands lie
PROBE_HEAP = 1024 # where wai_alloc's first region begins
def bits(ty, value):
"""An operand or a result as bytes, little-endian, lane 0 first."""
if ty == "f32":
return struct.pack("<I", value)
if ty == "f64":
return struct.pack("<Q", value)
lane = "<I" if ty == "f32x4" else "<Q"
return b"".join(struct.pack(lane, v) for v in value)
def expected(ty):
"""What the deterministic profile prescribes for a NaN result of type `ty`: the positive
canonical NaN, in every lane."""
if ty in CANONICAL:
return bits(ty, CANONICAL[ty])
lanes = 4 if ty == "f32x4" else 2
return bits(ty, (CANONICAL["f32" if ty == "f32x4" else "f64"],) * lanes)
def nan_branch(ty):
"""The planted fault: a branch on whether the result is a NaN that writes the canonical
NaN itself, so the module's output no longer shows the engine's NaN. With the result on
the stack, it leaves the result, or the canonical NaN in its place. Locals 6, 7 and 8 of
`wai_run` are its `f32`, `f64` and `v128`."""
if ty in ("f32", "f64"):
idx, ne, vt = (6, 0x5C, F32) if ty == "f32" else (7, 0x62, F64)
const = b(0x43 if ty == "f32" else 0x44) + Blob(bits(ty, CANONICAL[ty]))
return cat(b(0x21, idx), b(0x20, idx), b(0x20, idx), b(ne),
b(0x04, vt), const, b(0x05), b(0x20, idx), b(END))
ne = simd(0x42 if ty == "f32x4" else 0x48)
mask = cat(b(0x20, 8), b(0x20, 8), ne)
return cat(b(0x21, 8), mask, simd(0x53), # v128.any_true
b(0x04, V128), v128_const(expected(ty)), b(0x20, 8), mask, simd(0x52), # v128.bitselect
b(0x05), b(0x20, 8), b(END))
def probe_module(planted=False):
"""§9.3's probe: `minimal-int` whose `wai_run`, for any input, writes the result of each
case at the output's offset, in order, and returns 0. Its operands lie in a data segment
and are loaded at run time, so no compiler folds an operation. Its memory is one page,
its minimum its maximum, with no `memory.grow` (R13); `wai_alloc` hands out regions from
a bump pointer, a mutable `i32` global, above the operands, and 0 past the page.
With `planted`, each result passes through `nan_branch` before it is stored."""
data, operand_at, out = bytearray(), [], 0
instrs, out_at = [], []
for n, op, ty, operands in PROBE:
size, load, _ = PROBE_TYPES[ty]
ats = []
for v in operands:
while len(data) % size:
data.append(0)
ats.append(PROBE_DATA + len(data))
data += bits(ty, v)
operand_at.append(ats)
first = True
for (n, op, ty, operands), ats in zip(PROBE, operand_at):
rty, opcode = PROBE_OPS[op]
size, _, store = PROBE_TYPES[rty]
_, load, _ = PROBE_TYPES[ty]
while out % size:
out += 1
out_at.append(out)
loads = []
for at in ats:
ld = load()
if first and not planted:
ld = mark("item", ld)
first = False
loads.append(cat(i32_const(at), ld))
instrs.append(cat(i32_const(out), *loads, opcode(),
nan_branch(rty) if planted else Blob(), store()))
out += size
m = Module()
m.memories = [limits(1, 1)]
m.globals = [b(I32, 0x01) + i32_const(PROBE_HEAP) + b(END)]
m.datas = [uleb(0) + i32_const(PROBE_DATA) + b(END) + uleb(len(data)) + Blob(bytes(data))]
m.alloc_body = cat(
b(0x20, 0x00), i32_const(65536), b(0x23, 0x00), b(0x6B), b(0x4B), # len > 65536 - top
b(0x04, I32), i32_const(0),
b(0x05), b(0x23, 0x00), b(0x23, 0x00), b(0x20, 0x00), b(0x6A), b(0x24, 0x00),
b(END), b(END))
m.out_len_body = cat(i32_const(out), b(END))
m.run_rest = cat(*instrs, i32_const(0), b(END))
if planted:
m.run_locals = uleb(3) + uleb(1) + mark("item", b(F32)) + uleb(1) + b(F64) + uleb(1) + b(V128)
assert len(data) + PROBE_DATA <= PROBE_HEAP and out <= PROBE_DATA
return m.build(), out
def probe_vectors():
"""probe.txt: §9.3's cases, each with its operands and the result the deterministic
profile prescribes, and the probe's bytes, the whole output."""
lines = [
"# The probe of waism.md §9.3, written by build.py: `<case> <operation> <operands>",
"# <result>`, one line a case, each operand and result as its bits, a vector's lanes",
"# comma-joined with lane 0 first, the result the one the deterministic profile",
"# prescribes, little-endian hex. Last, `bytes <hex>`: the probe's bytes, the output",
"# of its wai_run, every result in order. probe.wasm is the probe; probe-nan-branch.wasm",
"# is it with a NaN-dependent branch planted after each operation.",
]
whole = b""
for n, op, ty, operands in PROBE:
rty, _ = PROBE_OPS[op]
width = 8 if ty in ("f32", "f32x4") else 16
ops = " ".join(
",".join(f"0x{lane:0{width}x}" for lane in (v if isinstance(v, tuple) else (v,)))
for v in operands)
want = expected(rty)
whole += want
lines.append(f"{n} {op} {ops} {want.hex()}")
lines.append(f"bytes {whole.hex()}")
return ("\n".join(lines) + "\n").encode("utf-8"), whole
def verdict(spec, blob):
if spec == "held":
return "held"
if spec == "R1":
return "refused R1 0"
if spec == "R2":
return "refused R2 -"
rule, where = spec
at = len(blob) if where == "end" else blob.marks[where]
return f"refused {rule} {at}"
def outputs():
files = {}
lines = [
"# The checker corpus of waism.md §9.1, §9.2's grow-swallowed and §9.3's probe,",
"# written by build.py: `<case> <level> <caps> <verdict>`, one line a verdict.",
]
for nm, blob, rows in case_table():
files[f"{nm}.wasm"] = blob.data
for level, caps, spec, derived in rows:
line = f"{nm} {level} {caps} {verdict(spec, blob)}"
lines.append(line + (" # derived" if derived else ""))
files["cases.txt"] = ("\n".join(lines) + "\n").encode("utf-8")
text, whole = probe_vectors()
assert len(whole) == probe_module()[1]
files["probe.txt"] = text
return files
def limit_cases():
"""(case, blob, verdict-spec) for each validation limit of §4.2, at it and one past it."""
out = []
nop_type = functype([], [])
def both(nm, at_limit, past, spec):
out.append((f"{nm}-at-limit", at_limit, spec))
out.append((f"{nm}-past-limit", past, "R2"))
def module(change):
m = Module()
change(m)
return m.build()
# types: entries of the type section, and the types they define
def plain_types(n):
return lambda m: m.types.extend([nop_type] * (n - 3))
both("types", module(plain_types(1_000_000)), module(plain_types(1_000_001)), "held")
def empty_groups(n):
def change(m):
m.types.append(mark("item", b(0x4E) + uleb(0)))
m.types.extend([b(0x4E) + uleb(0)] * (n - 4))
return change
both("type-entries", module(empty_groups(1_000_000)), module(empty_groups(1_000_001)), ("R12", "item"))
def one_group(n):
return lambda m: m.types.append(mark("item", b(0x4E) + vec([nop_type] * (n - 3))))
both("types-in-a-group", module(one_group(1_000_000)), module(one_group(1_000_001)), ("R12", "item"))
def functions(n):
def change(m):
m.funcs += [T_ABI] * (n - 5)
m.extra_bodies = [body(cat(i32_const(0), b(END)))] * (n - 5)
return change
both("functions", module(functions(1_000_000)), module(functions(1_000_001)), ("R11", "function-section"))
def globals(n):
return lambda m: m.globals.extend([b(I32, 0x00) + i32_const(0) + b(END)] * n)
both("globals", module(globals(1_000_000)), module(globals(1_000_001)), "held")
def tags(n):
def change(m):
m.types.append(nop_type)
m.tags = [b(0x00) + uleb(3)] * n
return change
both("tags", module(tags(1_000_000)), module(tags(1_000_001)), ("R12", "tag-section"))
def tables(n):
return lambda m: m.tables.extend([b(FUNCREF) + limits(0, 0), mark("item", b(FUNCREF) + limits(0, 0))]
+ [b(FUNCREF) + limits(0, 0)] * (n - 2))
both("tables", module(tables(100_000)), module(tables(100_001)), ("R7", "item"))
def memories(n):
return lambda m: m.memories.extend([mark("item", limits(1, 2))] + [limits(1, 2)] * (n - 2))
both("memories", module(memories(100)), module(memories(101)), ("R6", "item"))
def elements(n):
return lambda m: setattr(m, "elems", [uleb(1) + b(0x00) + uleb(0)] * n)
both("element-segments", module(elements(100_000)), module(elements(100_001)), "held")
def datas(n):
return lambda m: setattr(m, "datas", [uleb(1) + uleb(0)] * n)
both("data-segments", module(datas(100_000)), module(datas(100_001)), "held")
def items(n):
return lambda m: setattr(m, "elems", [uleb(1) + b(0x00) + uleb(n) + Blob(bytes(n))])
both("segment-items", module(items(10_000_000)), module(items(10_000_001)), "held")
def export_name(n):
return lambda m: setattr(m, "exports", m.default_exports() + [mark("item", export("x" * n, 0x00, 0))])
both("name-of-an-export", module(export_name(100_000)), module(export_name(100_001)), ("R10", "item"))
def import_name(n):
def change(m):
m.imports = [mark("item", name("env") + name("g" * n) + b(0x03) + b(I32, 0x00))]
return change
both("name-of-an-import", module(import_name(100_000)), module(import_name(100_001)), ("R5", "item"))
def custom_name(n):
return lambda m: setattr(m, "customs", [name("c" * n)])
both("name-of-a-custom-section", module(custom_name(100_000)), module(custom_name(100_001)), "held")
def body_bytes(n):
# wai_run's body: its declaration of no locals (1 byte), nops, then its own 13 bytes.
def change(m):
m.run_prefix = Blob(b"\x01" * (n - 1 - len(store_and_return())))
return change
both("body", module(body_bytes(7_654_321)), module(body_bytes(7_654_322)), "held")
def locals_(n):
# wai_run's six parameters and n - 6 locals.
return lambda m: setattr(m, "run_locals", uleb(1) + uleb(n - 6) + b(I32))
both("locals", module(locals_(50_000)), module(locals_(50_001)), "held")
def params(n):
return lambda m: m.types.append(functype([I32] * n, []))
both("parameters", module(params(1_000)), module(params(1_001)), "held")
def results(n):
return lambda m: m.types.append(functype([], [I32] * n))
both("results", module(results(1_000)), module(results(1_001)), "held")
def fields(n):
return lambda m: m.types.append(mark("item", b(0x5F) + vec([b(I32, 0x00)] * n)))
both("struct-fields", module(fields(10_000)), module(fields(10_001)), ("R12", "item"))
def catches(n):
# A block, and in it a try_table whose catch_all clauses all name the block.
return lambda m: setattr(m, "run_prefix", cat(
b(0x02, 0x40), mark("item", b(0x1F, 0x40)), uleb(n), Blob(b"\x02\x00" * n), b(END), b(END)))
both("catches", module(catches(10_000)), module(catches(10_001)), ("R12", "item"))
def depth(d):
# A chain of struct subtypes: the first declares no supertype (depth 0), each next
# names the one before.
def change(m):
m.types.append(mark("item", b(0x50) + uleb(0) + b(0x5F) + uleb(0)))
for k in range(1, d + 1):
m.types.append(b(0x50) + uleb(1) + uleb(3 + k - 1) + b(0x5F) + uleb(0))
return change
both("subtype-depth", module(depth(63)), module(depth(64)), ("R12", "item"))
def interface(total):
# The six exports of §5 weigh 23 (memory 1; wai_abi, wai_out_ptr, wai_out_len 3 each;
# wai_alloc 4; wai_run 9); each further export of the memory weighs 1.
def change(m):
extra = total - 23
names = [f"m{i:x}" for i in range(extra)]
ex = m.default_exports()
ex.append(mark("item", export(names[0], 0x02, 0)))
ex.extend(export(nm, 0x02, 0) for nm in names[1:])
m.exports = ex
return change
both("interface", module(interface(999_998)), module(interface(999_999)), ("R10", "item"))
return out
def write_limits(directory):
os.makedirs(directory, exist_ok=True)
lines = ["# The validation limits of waism.md §4.2, written by build.py --limits."]
for nm, blob, spec in limit_cases():
assert len(blob) <= 16_777_216, nm
with open(os.path.join(directory, f"{nm}.wasm"), "wb") as f:
f.write(blob.data)
for level in ("integer", "float"):
lines.append(f"{nm} {level} - {verdict(spec, blob)}")
with open(os.path.join(directory, "cases.txt"), "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
print(f"build.py: wrote {len(lines) - 1} verdicts of the validation limits to {directory}")
return 0
def main(argv):
if len(argv) == 3 and argv[1] == "--limits":
return write_limits(argv[2])
check = argv[1:] == ["--check"]
if argv[1:] and not check:
print("usage: build.py [--check | --limits DIR]", file=sys.stderr)
return 2
want = outputs()
have = {n for n in os.listdir(HERE) if n.endswith(".wasm") or n in ("cases.txt", "probe.txt")}
if check:
bad = []
for n, data in sorted(want.items()):
p = os.path.join(HERE, n)
if not os.path.isfile(p):
bad.append(f"{n}: missing")
elif open(p, "rb").read() != data:
bad.append(f"{n}: differs from what build.py writes")
bad += [f"{n}: not written by build.py" for n in sorted(have - set(want))]
for line in bad:
print(f"build.py --check: {line}", file=sys.stderr)
if not bad:
print(f"build.py --check: {len(want) - 2} modules, cases.txt and probe.txt are what build.py writes")
return 1 if bad else 0
for n in sorted(have - set(want)):
os.remove(os.path.join(HERE, n))
for n, data in want.items():
with open(os.path.join(HERE, n), "wb") as f:
f.write(data)
print(f"build.py: wrote {len(want) - 2} modules, cases.txt and probe.txt")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))