#!/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 `.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: ` `, where `` is `integer` or `float`, `` is `-` for the profile's caps (§4.2) or the caps the row states, comma-joined `name=value`, and `` 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(" 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(" 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: ` ", "# `, 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 `: 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: ` `, 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))