Implement zero-dependency Wasm binary parser#754
Conversation
Wasm 3.0 compliance review — binary parser (PR #754)Review of the in-repo binary parser in The PR body declares a set of intentional divergences (typed select, blocktype Summary
Verified correct while reviewing: signed/unsigned LEB128 range logic, Finding 1 —
|
The i64 (memory64/table64) limit encodings are 0x04/0x05 per the Wasm 3.0 grammar; 0x02/0x03 are the shared-memory (threads) flags for i32 and were being misdecoded as i64. Address type is discarded downstream regardless, so this only affects which flag byte is recognized as which type. Adds regression tests covering the i32/i64 limit cases and confirming the shared-memory flags are now rejected instead of misdecoded.
parse_custom_section skipped the section id byte but then read_bytes(n, s) used n (the id, always 0) as the length instead of reading the section's size:u32 first. This left the stream misaligned after any custom section, silently dropping every section that followed. Also switch the "no more custom sections" sentinel from falsy bytes to explicit None, since an empty-payload custom section is valid and isn't the same as "not a custom section". Adds a regression test that builds raw bytes with custom sections interleaved between real sections and confirms the type/function/export sections still parse correctly.
0xFC 10 (memory.copy) and 0xFC 11 (memory.fill) map to the existing aCopy/aFill K constructs. Their memory index operands are parsed and discarded, matching the existing memory.size/memory.grow handling, since the K semantics has a single implicit memory. Adds parametrized unit tests (zero and multi-byte memidx values, plus a trailing sentinel byte) proving the parser consumes exactly the memidx operands rather than a fixed byte count, and exercises both opcodes through the full K interpreter via instrs.wat.
Went through each finding:
|
parse_module never checked that the input stream was fully consumed, so an unrecognized section id or garbage appended after a well-formed module was silently ignored instead of raising a parse error.
Dead code: nothing in the parser calls it, and its broad exception swallow (WasmParseError | IndexError | ValueError) risked masking real bugs if it were ever reused.
zip(function_section, code_section, strict=True) surfaced a bare Python ValueError on mismatch, unlike every other malformed-input path in the parser, which raises WasmParseError.
|
Fixed after self review:
|
reset(pos, s) only ran on the success path, so a short read at EOF (fewer than n bytes remaining but more than zero) left the stream advanced past the bytes it consumed while trying, violating peek's no-side-effects contract.
'if bt in* end' is a valid encoding that omits the 0x05 else opcode; the parser only handled the explicit-else form and raised 'Unsupported opcode: 0x0b' on the then-branch terminator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The K semantics models a single memory; multi-memory support is future work. Previously the memory index was parsed and discarded in memarg, memory.size/grow/copy/fill, active data segments, and memory exports, so a valid multi-memory module would silently execute against memory 0. Now memidx raises WasmParseError on any index other than 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Explain the constructed bytecode in TestCustomSections and TestFuncCodeLengthMismatch, state the sentinel-byte contract once at class level instead of four times, explain why the shared-memory limit flags 0x02/0x03 must be rejected, note the intent behind the LEB128 boundary values, and untangle the peek_bytes test comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sys.setrecursionlimit(1500000000) was an import-time side effect inherited from the 2020 py-wasm-era script, and the value effectively disabled Python's recursion guard. The limit is only needed because pyk serializes kore terms recursively (pyk.kore.syntax.Pattern.write) and the depth grows with module size: basic-features.wat needs about 4000, while the default 1000 is enough for everything else. Set 20000 inside run_module, never lowering an externally raised limit. Also remove an assert made dead by check=True and a try/except that re-raised as a bare Exception, hiding the original exception type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The limits parser decoded the memory64/table64 flags (0x04/0x05) and returned the i64 address type, which every caller discarded; a table import's reference type was likewise parsed and dropped since the K ImportDefn sort has no reftype slot. In both cases a valid module using the feature would silently execute with wrong (32-bit / funcref) semantics. The K AST cannot represent either feature, so raise a parse error instead, keeping the spec-correct handling in place as commented-out skeleton for future support. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Magic and version checks no longer rely on assert (stripped under python -O) and report expected vs. actual bytes; invalid UTF-8 in a name raises WasmParseError instead of leaking UnicodeDecodeError; the blocktype error names the unsupported type-index (i33) form; and the 0xFC handler catches WasmParseError instead of Exception so internal errors are not disguised as parse errors. No accept/reject behavior changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This PR adds a new zero-dependency binary parser in
pykwasmthat parses Wasm bytecode and produces KAst terms of wasm-semantics. The implementation closely follows the WebAssembly 3.0 binary format specification.Previously, the parser relied on a py-wasm fork, as described in issue #524. That dependency has been unmaintained since 2019 and blocked support for WASM 2.0+ opcodes.
Changes
py-wasmdependency and the old py-wasm-based translation inwasm2kastBlockMetaDatafield, which was previously used for coverage trackingImplement differential testing to compare the old py-wasm-based parser with the new parserSpec Divergences in the Binary Parser
The parser follows the current WebAssembly spec (WASM 3.0) for decoding, but the K semantics predates several additions. Where the K AST cannot represent a feature, the parser rejects the module with a clear error rather than silently discarding information; where information is discarded, it is semantically inert (noted per entry below).
Blocktype type-index form (
instructions.py) — Ablocktypecan be a type index encoded asi33. Only the empty and single-valtype cases are handled; thei33case raises a parse error. The K semantics block types cannot reference the type section.Typed select (
instructions.py) — The typedselectvariant (0x1C) carries a value-type vector. It is parsed and discarded; the untypedSELECTnode is emitted. The K semantics has a single untypedselect.Memory load/store memarg (
instructions.py) —memargencodes an alignment hint and an optional memory index alongside the offset. Only the offset is forwarded to the AST; alignment is discarded (it is a hint with no runtime semantics). Affects all 23 load/store opcodes. The K semantics models a single memory with offset-only instructions.Multi-memory (
indices.py,memidx) — The K semantics models a single memory; multi-memory support is future work. A non-zero memory index anywhere (load/storememarg,memory.size/memory.grow/memory.copy/memory.fill, active data segments, memory exports) raises a parse error rather than being silently treated as memory 0. Memory index 0 is parsed and discarded where the K semantics has no slot for it.Recursive types (
module.py) — The spec wraps all type definitions inrectypegroups that can be mutually recursive. We only accept singleton groups; actual recursion raises a parse error. The K semantics has no recursive type construct.Table inline initializer (
module.py) — The spec allows an optional inline initializer for tables (prefix0x40 0x00). Encountering it raises a parse error. No counterpart exists in the K semantics.Address type in limits (
types.py,limits) — Limit encodings0x04/0x05signal 64-bit (memory64/table64) addressing, which the K semantics cannot represent: it assumes 32-bit addressing throughout. They raise a parse error rather than being silently treated as 32-bit. The shared-memory encodings0x02/0x03(threads proposal) likewise raise a parse error, since the K semantics has no notion of shared memory.Only two reference types are supported:
funcrefandexternref(types.py) — The GC proposal added further reference types (any,eq,i31,struct,array,none,noextern,nofunc,exn,noexn) and a general0x63/0x64encoding for(ref null? <heap type>). None of these are recognized; onlyfuncref(0x70) andexternref(0x6F) parse.v128(SIMD) is likewise unrecognized wherever a value type is expected. The K semantics predates the GC and SIMD proposals and has no construct for any of these types.NaN payloads (
floats.py) —f32.const/f64.constare decoded via Python'sstruct.unpack, which collapses distinct NaN bit patterns (sign, payload, canonical vs. arithmetic) into a single Pythonfloat. This is not introduced by the binary parser: the K semantics'Floatsort has no representation for NaN payloads either (see theTODOinwasm.mdon theFloatproduction).Import section externtype (
types.py) — The KImportDefnsort still has the WASM 1.0 structure, so import descriptors are decoded from the WASM 3.0 grammar but returned as WASM 1.0 nodes. Two kinds of import cannot be represented in that structure and raise a parse error: tag imports (0x04), and table imports with a reference type other thanfuncref.