From 686c073e83b027b8c9aa8c08766792a1aa4a0157 Mon Sep 17 00:00:00 2001 From: nelsonduarte Date: Fri, 17 Jul 2026 18:01:44 +0100 Subject: [PATCH 1/3] fix(wasm): emit $pow10_i32 for parse_float's bignum slow path parse_float lowers to $parse_float, whose hard-rounding slow path calls $bn_mul_pow10, which in turn calls $pow10_i32. That helper was emitted only under the Float-format gate (_uses_format_str AND _uses_float_format), so a program that parses a Float but never interpolates one left $pow10_i32 undefined and the Wasm module failed to assemble with "unknown func: failed to find name $pow10_i32", whether the parse result was bound or discarded. Give $pow10_i32 its own idempotency latch (_pow10_emitted) and have _emit_bignum_helpers pull it in, mirroring how the Grisu float-format path already emits it. Over-emitting an unused helper is harmless dead code; a called-but-unemitted one is a hard build failure, so err toward over-emission. Tests: - Un-exempt parse_float from the discarded-call sweep and add it to the free-function recipes so it is compiled + validated. - Add TestWatCallClosure, a feature-agnostic guard asserting no emitted module calls a function it neither defines nor imports, over a corpus that lights up each gated helper family (including parse_float without any Float format). Confirmed it bites when $pow10_i32 is withheld. - Add a no-format parse_float cross-backend parity case (the existing probe always formatted the Float, masking the bug). --- capa/ir/_emit_wasm/__init__.py | 5 ++ capa/ir/_emit_wasm/_grisu.py | 14 +++- tests/test_ir_wasm.py | 123 +++++++++++++++++++++++++++++++-- tests/test_ir_wasm_parity.py | 31 +++++++++ 4 files changed, 166 insertions(+), 7 deletions(-) diff --git a/capa/ir/_emit_wasm/__init__.py b/capa/ir/_emit_wasm/__init__.py index 23b94592..a6f6572f 100644 --- a/capa/ir/_emit_wasm/__init__.py +++ b/capa/ir/_emit_wasm/__init__.py @@ -297,6 +297,11 @@ def __init__( # string-to-float parser; emit them at most once even when both # paths are present. self._bignum_helpers_emitted = False + # ``$pow10_i32`` is called by both Grisu2 digit generation and + # the bignum ``$bn_mul_pow10`` helper (used by parse_float). Its + # own latch lets whichever caller is emitted first pull it in, + # so it is never left undefined (see _emit_pow10_i32_function). + self._pow10_emitted = False # Module-level string pool: maps a Python string to its # (offset, length_in_bytes) in the data segment. Populated # by ``_intern_string`` as the emitter walks the function diff --git a/capa/ir/_emit_wasm/_grisu.py b/capa/ir/_emit_wasm/_grisu.py index 25bbe262..f673bdb8 100644 --- a/capa/ir/_emit_wasm/_grisu.py +++ b/capa/ir/_emit_wasm/_grisu.py @@ -164,7 +164,14 @@ def _emit_pow10_i32_function(self) -> None: decimal digit at a time from the integer part of the scaled significand. Larger exponents are unreachable (kappa starts at 10 but the first divisor is 10**9 so we - only ever index by 0..9).""" + only ever index by 0..9). + + Idempotent: emitted at most once whether pulled in by the + float-format Grisu path or by the ``$bn_mul_pow10`` bignum + helper that backs parse_float.""" + if self._pow10_emitted: + return + self._pow10_emitted = True self._write("(func $pow10_i32 (param $k i32) (result i32)") self._indent += 1 # Hand-rolled jump table -- ten branches is much smaller @@ -1176,6 +1183,11 @@ def _emit_bignum_helpers(self) -> None: if self._bignum_helpers_emitted: return self._bignum_helpers_emitted = True + # $bn_mul_pow10 calls $pow10_i32, so ensure that helper is + # present whenever the bignum family is. Idempotent: the latch + # inside _emit_pow10_i32_function keeps the float-format path + # from emitting it twice. + self._emit_pow10_i32_function() # $bn_alloc(n) -> ptr : 4 + n*4 bytes, count=n, limbs zeroed. self._emit_wat_block( """ diff --git a/tests/test_ir_wasm.py b/tests/test_ir_wasm.py index c6d44f0c..3975cb20 100644 --- a/tests/test_ir_wasm.py +++ b/tests/test_ir_wasm.py @@ -36,6 +36,7 @@ from __future__ import annotations +import re import shutil import typing import unittest @@ -9207,6 +9208,7 @@ def _recipe(ty, method, caps, setup, expr): "to_json": ([], ["__JSON__"], "to_json(j)"), "new_map": ([], [], "new_map()"), "new_set": ([], [], "new_set()"), + "parse_float": ([], [], 'parse_float("1.0")'), } # Free-function builtins deliberately outside the sweep, with the @@ -9226,12 +9228,6 @@ def _recipe(ty, method, caps, setup, expr): "declassify", # Python-interop FFI: rejected on the Wasm backend by design. "py_import", "py_invoke", - # Pre-existing unrelated Wasm gap: parse_float's emitted body calls - # a `$pow10_i32` helper that is never defined, so the module fails - # to assemble whether the result is BOUND or DISCARDED. That is a - # missing-runtime-helper bug, not a discard-drop bug; sweeping it - # would assert on an unrelated failure. - "parse_float", } @@ -9479,6 +9475,121 @@ def test_every_discarded_emit_path_variant_validates(self): ) +# ---------------------------------------------------------------------- +# WAT call-closure guard. +# +# Every ``call $x`` in an emitted module must resolve to a function that +# the module either DEFINES (``(func $x ...)``) or IMPORTS +# (``(import ... (func $x ...))``). The conditionally-emitted runtime +# helpers ($pow10_i32, $ftoa, $bn_*, $str_cmp, ...) are gated on feature +# predicates, and a called-but-ungated helper produces a module that +# fails to assemble ("unknown func"). This guard walks a corpus that +# lights up each gated feature (and the combinations that previously +# left a callee ungated, notably parse_float WITHOUT any Float +# formatting) and asserts the call graph is closed. +# ---------------------------------------------------------------------- + +_WAT_CLOSURE_CORPUS: list[tuple[str, str]] = [ + # The Ticket-1 regression: parse_float pulls in $bn_mul_pow10, which + # calls $pow10_i32; without a Float format nothing else emits it. + ("parse_float_no_format", + "fun main(stdio: Stdio)\n" + ' let f = parse_float("3.5")\n' + ' stdio.println("done")\n'), + ("parse_float_discarded", + "fun main(stdio: Stdio)\n" + ' parse_float("3.5")\n' + ' stdio.println("done")\n'), + # Float formatting alone: the other $pow10_i32 caller (Grisu2). + ("float_format_only", + "fun main(stdio: Stdio)\n" + " let x = 3.5\n" + ' stdio.println("${x}")\n'), + # Both callers present: the latch must not double-define anything. + ("parse_float_and_float_format", + "fun main(stdio: Stdio)\n" + ' let f = parse_float("3.5").unwrap_or(0.0)\n' + ' stdio.println("${f}")\n'), + ("parse_int", + "fun main(stdio: Stdio)\n" + ' let n = parse_int("3")\n' + ' stdio.println("done")\n'), + ("int_format", + "fun main(stdio: Stdio)\n" + " let n = 3\n" + ' stdio.println("${n}")\n'), + ("string_ops", + "fun main(stdio: Stdio)\n" + ' let s = "hello"\n' + " stdio.println(s.to_upper())\n"), + ("string_order_cmp", + "fun main(stdio: Stdio)\n" + ' let b = "a" < "b"\n' + ' stdio.println("done")\n'), + ("compound_equality", + "fun main(stdio: Stdio)\n" + " let a = [1, 2]\n" + " let b = [1, 2]\n" + " let eq = a == b\n" + ' stdio.println("done")\n'), + ("set_algebra", + "fun main(stdio: Stdio)\n" + " var s1 = new_set()\n" + " s1.add(1)\n" + " var s2 = new_set()\n" + " s2.add(2)\n" + " let u = s1.union(s2)\n" + ' stdio.println("done")\n'), + ("json_parse", + "fun main(stdio: Stdio)\n" + ' match parse_json("1")\n' + ' Ok(j) -> stdio.println("ok")\n' + ' Err(e) -> stdio.println("e")\n'), +] + +_WAT_FUNC_DECL = re.compile(r"\(func\s+\$(\w+)") +_WAT_IMPORT_FUNC = re.compile(r"\(import\b.*\(func\s+\$(\w+)") +_WAT_CALL = re.compile(r"\bcall\s+\$(\w+)") + + +def _wat_call_closure(wat: str) -> set[str]: + """Return the set of ``call $x`` targets with no defining or + importing ``(func $x ...)`` in the module (should be empty).""" + declared = set(_WAT_FUNC_DECL.findall(wat)) + imported = { + m + for line in wat.splitlines() + if "(import" in line + for m in _WAT_IMPORT_FUNC.findall(line) + } + called = set(_WAT_CALL.findall(wat)) + return called - declared - imported + + +class TestWatCallClosure(unittest.TestCase): + """Feature-agnostic guard: no emitted module may ``call`` a function + it neither defines nor imports. Structurally catches a callable-but- + ungated runtime helper for any future feature, not just parse_float. + """ + + def test_every_call_is_defined_or_imported(self): + for label, src in _WAT_CLOSURE_CORPUS: + with self.subTest(program=label): + # compile_wat runs the full pipeline (inject_into splices + # the bundled JSON parser, monomorphise specialises + # generics), so the WAT here is exactly what assembles. + _, types, ast_mod = _parse_lower(src) + wat = compile_wat(ast_mod, types=types) + missing = _wat_call_closure(wat) + self.assertEqual( + missing, set(), + msg=( + f"{label}: module calls undefined/unimported " + f"function(s): {sorted(missing)}" + ), + ) + + def _parse_and_analyze_ok(src: str): """Lex + parse + analyze, asserting the program is well-typed. Returns (ast_module, analysis_result).""" diff --git a/tests/test_ir_wasm_parity.py b/tests/test_ir_wasm_parity.py index b995973c..c36e4c8d 100644 --- a/tests/test_ir_wasm_parity.py +++ b/tests/test_ir_wasm_parity.py @@ -5185,6 +5185,37 @@ def gen() -> str: total += len(vals) self.assertGreater(total, 1000) + def test_parse_float_without_any_float_format(self): + # Regression for the Ticket-1 gap: parse_float pulls in the + # bignum slow path whose $bn_mul_pow10 calls $pow10_i32, but + # $pow10_i32 used to be emitted ONLY under Float formatting. + # A program that parses a Float but never interpolates one left + # $pow10_i32 undefined, so the Wasm module failed to assemble. + # The probe here deliberately renders NO Float (it prints an Int + # and a discriminant), so it exercises the parse path without + # the float-format path that masked the bug. + for src in ( + # Result bound but never formatted as a Float. + "fun main(stdio: Stdio)\n" + ' match parse_float("3.5")\n' + ' Some(f) -> stdio.println("ok")\n' + ' None -> stdio.println("none")\n', + # Result discarded entirely. + "fun main(stdio: Stdio)\n" + ' parse_float("3.5")\n' + ' stdio.println("done")\n', + ): + py_out = _capture_stdout(lambda: _run_python(src)) + wasm_out = _capture_stdout(lambda: _run_wasm(src)) + self.assertEqual( + py_out, wasm_out, + msg=( + "parse_float-no-format divergence.\n" + f"--- program ---\n{src}" + f"--- python ---\n{py_out}\n--- wasm ---\n{wasm_out}" + ), + ) + @unittest.skipUnless( _has_wasm_tools() and _has_wasmtime_py(), From 4f9b9778d47b8d540a41cb5c2c6dfa9504e44c22 Mon Sep 17 00:00:00 2001 From: nelsonduarte Date: Fri, 17 Jul 2026 18:11:45 +0100 Subject: [PATCH 2/3] fix(wasm): match Python's Fs/Db/Proc error text on the Wasm hosts Reading a missing file printed "failed to read 'x': [Errno 2] ..." on the Python backend but a bare "[Errno 2] ..." on both Wasm hosts, because the host binders passed the raw str(OSError) as the whole message and dropped the "failed to ''" wrap. Permission denials were terser too: they used ": " with no restriction cause, where Python names the op, the path repr, and the current allowed-prefix / restriction set via the shared ._deny helpers. Mirror the Python wording in both capa/runtime/_wasm_host.py and capa/runtime/_wasm_component_host.py: - OSError arms now build message=f"failed to {path!r}", cause=str(e), matching capa/runtime/_capabilities.py. The component host also stops using type(e).__name__ as the cause. - Every deny arm (pre-check and post-open) now routes through the shared Fs._deny / Db._deny / Proc._deny so the message and the restriction cause are byte-identical to the Python backend. The operation itself is unchanged: each host keeps its own syscall for its TOCTOU / sandbox guarantees, only the surfaced text is aligned. The --wasi compiled path (capa/ir/_emit_wasm/_wasi/_fs.py) is deliberately left alone; its message is a documented path-less contract with a discriminant-only parity test. Adds TestCapErrorMessageParity: runs the same fs read/write/mkdir/ list_dir failure and one deny per cap on the Python backend, the core WasmHost, and the Component Model host, asserting all three print the identical Err payload. Existing TOCTOU substring tests still pass. --- capa/runtime/_wasm_component_host.py | 60 ++++++--------- capa/runtime/_wasm_host.py | 92 +++++++++++----------- tests/test_ir_wasm_parity.py | 109 +++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 83 deletions(-) diff --git a/capa/runtime/_wasm_component_host.py b/capa/runtime/_wasm_component_host.py index 8fe878f1..f919ee69 100644 --- a/capa/runtime/_wasm_component_host.py +++ b/capa/runtime/_wasm_component_host.py @@ -70,6 +70,14 @@ def __init__(self, message: str, cause: str = "") -> None: self.cause = cause +def _deny_record(err) -> "IoErrorRecord": + """Render a shared Fs/Db/Proc ``_deny()`` result (an ``Err(IoError)``) + into an ``IoErrorRecord`` so the CM host's deny message + restriction + cause match the Python backend byte for byte.""" + io = err.error + return IoErrorRecord(message=io.message, cause=io.cause) + + class WasmComponentHost: """Wraps a Component Model ``.wasm`` artifact in a wasmtime linker pre-populated with Capa's ``capa:host/*`` interfaces. @@ -763,9 +771,7 @@ def fs_read(_store, handle: int, path: str): cause=str(handle), ) if not fs.allows(path): - return IoErrorRecord( - message=f"Fs capability does not permit read: {path}", - ) + return _deny_record(fs._deny("read", path)) # TOCTOU hardening (2026-06-10): same shared Fs._open_read # as the Python backend and the core-Wasm host; on a # restricted cap the open handle's true path is @@ -774,12 +780,10 @@ def fs_read(_store, handle: int, path: str): with fs._open_read(path) as f: return f.read() except PostOpenDenied: - return IoErrorRecord( - message=f"Fs capability does not permit read: {path}", - ) + return _deny_record(fs._deny("read", path)) except OSError as e: return IoErrorRecord( - message=str(e), cause=type(e).__name__, + message=f"failed to read {path!r}", cause=str(e), ) def fs_write(_store, handle: int, path: str, content: str): @@ -792,9 +796,7 @@ def fs_write(_store, handle: int, path: str, content: str): cause=str(handle), ) if not fs.allows(path): - return IoErrorRecord( - message=f"Fs capability does not permit write: {path}", - ) + return _deny_record(fs._deny("write", path)) # TOCTOU hardening: no truncation until the handle's true # path passes verification (shared Fs._open_write). try: @@ -802,12 +804,10 @@ def fs_write(_store, handle: int, path: str, content: str): f.write(content) return None except PostOpenDenied: - return IoErrorRecord( - message=f"Fs capability does not permit write: {path}", - ) + return _deny_record(fs._deny("write", path)) except OSError as e: return IoErrorRecord( - message=str(e), cause=type(e).__name__, + message=f"failed to write {path!r}", cause=str(e), ) def fs_restrict_to(_store, parent: int, prefix: str) -> int: @@ -836,15 +836,13 @@ def fs_mkdir(_store, handle: int, path: str): cause=str(handle), ) if not fs.allows(path): - return IoErrorRecord( - message=f"Fs capability does not permit mkdir: {path}", - ) + return _deny_record(fs._deny("mkdir", path)) try: os.makedirs(path, exist_ok=True) return None except OSError as e: return IoErrorRecord( - message=str(e), cause=type(e).__name__, + message=f"failed to mkdir {path!r}", cause=str(e), ) def fs_list_dir(_store, handle: int, path: str): @@ -858,14 +856,12 @@ def fs_list_dir(_store, handle: int, path: str): cause=str(handle), ) if not fs.allows(path): - return IoErrorRecord( - message=f"Fs capability does not permit list_dir: {path}", - ) + return _deny_record(fs._deny("list_dir", path)) try: return sorted(os.listdir(path)) except OSError as e: return IoErrorRecord( - message=str(e), cause=type(e).__name__, + message=f"failed to list {path!r}", cause=str(e), ) def fs_allows(_store, handle: int, path: str) -> bool: @@ -991,9 +987,7 @@ def db_exec(_store, handle: int, path: str, sql: str): cause=str(handle), ) if not db.allows(path): - return IoErrorRecord( - message=f"Db capability does not permit exec: {path}", - ) + return _deny_record(db._deny(path, "exec")) try: # _connect_verified applies the post-open symlink-swap # guard (audit 2026-06-17) and installs the @@ -1007,9 +1001,7 @@ def db_exec(_store, handle: int, path: str, sql: str): conn.close() return None except PostOpenDenied: - return IoErrorRecord( - message=f"Db capability does not permit exec: {path}", - ) + return _deny_record(db._deny(path, "exec")) except (sqlite3.Error, OSError, ValueError) as e: return IoErrorRecord( message="SQLite exec failed", cause=str(e), @@ -1023,9 +1015,7 @@ def db_query(_store, handle: int, path: str, sql: str): cause=str(handle), ) if not db.allows(path): - return IoErrorRecord( - message=f"Db capability does not permit query: {path}", - ) + return _deny_record(db._deny(path, "query")) try: conn = db._connect_verified(path) try: @@ -1043,9 +1033,7 @@ def db_query(_store, handle: int, path: str, sql: str): ] return _stdlib_json.dumps(stringified) except PostOpenDenied: - return IoErrorRecord( - message=f"Db capability does not permit query: {path}", - ) + return _deny_record(db._deny(path, "query")) except (sqlite3.Error, OSError, ValueError) as e: return IoErrorRecord( message="SQLite query failed", cause=str(e), @@ -1090,9 +1078,7 @@ def proc_exec(_store, handle: int, cmd: str, args_json: str): cause=str(handle), ) if not proc.allows(cmd): - return IoErrorRecord( - message=f"Proc capability does not permit exec: {cmd}", - ) + return _deny_record(proc._deny(cmd)) try: tail = _stdlib_json_proc.loads(args_json) except (ValueError, TypeError) as e: diff --git a/capa/runtime/_wasm_host.py b/capa/runtime/_wasm_host.py index dbe71c54..f34926e1 100644 --- a/capa/runtime/_wasm_host.py +++ b/capa/runtime/_wasm_host.py @@ -1552,6 +1552,15 @@ def _write_result_err_ioerror(caller, ret_area, message, cause=""): caller, c_len.to_bytes(4, "little"), ret_area + 16, ) + def _write_deny(caller, ret_area, err): + # Render a shared Fs._deny() result into the Err arm so the + # deny message + allowed-prefix cause match the Python + # backend byte for byte. + io = err.error + _write_result_err_ioerror( + caller, ret_area, io.message, io.cause, + ) + def _lookup_fs_or_err(caller, handle, ret_area): """Resolve the receiver Fs cap from the handle table. On failure (unknown handle / wrong type / zero sentinel) @@ -1595,10 +1604,7 @@ def fs_read(caller, handle, path_ptr, path_len, ret_area): if fs is None: return if not fs.allows(path): - _write_result_err_ioerror( - caller, ret_area, - f"Fs capability does not permit read: {path}", - ) + _write_deny(caller, ret_area, fs._deny("read", path)) return # TOCTOU hardening (2026-06-10): route through the same # Fs._open_read used by the Python backend, which @@ -1612,12 +1618,11 @@ def fs_read(caller, handle, path_ptr, path_len, ret_area): s_ptr, s_len = _alloc_utf8(caller, content) _write_result_ok_string(caller, ret_area, s_ptr, s_len) except PostOpenDenied: + _write_deny(caller, ret_area, fs._deny("read", path)) + except OSError as e: _write_result_err_ioerror( - caller, ret_area, - f"Fs capability does not permit read: {path}", + caller, ret_area, f"failed to read {path!r}", str(e), ) - except OSError as e: - _write_result_err_ioerror(caller, ret_area, str(e)) def fs_write(caller, handle, p_ptr, p_len, c_ptr, c_len, ret_area): if self._memory is None or self._alloc_export is None: @@ -1649,10 +1654,7 @@ def fs_write(caller, handle, p_ptr, p_len, c_ptr, c_len, ret_area): if fs is None: return if not fs.allows(path): - _write_result_err_ioerror( - caller, ret_area, - f"Fs capability does not permit write: {path}", - ) + _write_deny(caller, ret_area, fs._deny("write", path)) return # TOCTOU hardening: same routing as fs_read; on a # restricted cap the open does NOT truncate until the @@ -1663,12 +1665,11 @@ def fs_write(caller, handle, p_ptr, p_len, c_ptr, c_len, ret_area): f.write(content) _write_result_ok_unit(caller, ret_area) except PostOpenDenied: + _write_deny(caller, ret_area, fs._deny("write", path)) + except OSError as e: _write_result_err_ioerror( - caller, ret_area, - f"Fs capability does not permit write: {path}", + caller, ret_area, f"failed to write {path!r}", str(e), ) - except OSError as e: - _write_result_err_ioerror(caller, ret_area, str(e)) self.linker.define_func( "capa:host/fs", "read", ft_fs_read_indirect, @@ -1863,16 +1864,15 @@ def fs_mkdir(caller, handle, path_ptr, path_len, ret_area): if fs is None: return if not fs.allows(path): - _write_result_err_ioerror( - caller, ret_area, - f"Fs capability does not permit mkdir: {path}", - ) + _write_deny(caller, ret_area, fs._deny("mkdir", path)) return try: _os_mod.makedirs(path, exist_ok=True) _write_result_ok_unit(caller, ret_area) except OSError as e: - _write_result_err_ioerror(caller, ret_area, str(e)) + _write_result_err_ioerror( + caller, ret_area, f"failed to mkdir {path!r}", str(e), + ) self.linker.define_func( "capa:host/fs", "mkdir", ft_mkdir, @@ -1906,15 +1906,14 @@ def fs_list_dir(caller, handle, path_ptr, path_len, ret_area): if fs is None: return if not fs.allows(path): - _write_result_err_ioerror( - caller, ret_area, - f"Fs capability does not permit list_dir: {path}", - ) + _write_deny(caller, ret_area, fs._deny("list_dir", path)) return try: entries = sorted(_os_mod.listdir(path)) except OSError as e: - _write_result_err_ioerror(caller, ret_area, str(e)) + _write_result_err_ioerror( + caller, ret_area, f"failed to list {path!r}", str(e), + ) return n = len(entries) data_ptr = self._host_alloc(caller, n * 8) if n else 0 @@ -2341,6 +2340,14 @@ def _write_result_err_ioerror(caller, ret_area, message, cause=""): caller, c_len.to_bytes(4, "little"), ret_area + 16, ) + def _write_deny(caller, ret_area, err): + # Render a shared Db._deny() result into the Err arm so the + # deny message + restriction cause match the Python backend. + io = err.error + _write_result_err_ioerror( + caller, ret_area, io.message, io.cause, + ) + def _lookup_db_or_err(caller, handle, ret_area): """Resolve the receiver Db cap. On failure write an Err(IoError) into ``ret_area`` and return None; @@ -2381,10 +2388,7 @@ def db_exec( if db is None: return if not db.allows(path): - _write_result_err_ioerror( - caller, ret_area, - f"Db capability does not permit exec: {path}", - ) + _write_deny(caller, ret_area, db._deny(path, "exec")) return try: # _connect_verified applies the post-open symlink-swap @@ -2399,10 +2403,7 @@ def db_exec( conn.close() _write_result_ok_unit(caller, ret_area) except PostOpenDenied: - _write_result_err_ioerror( - caller, ret_area, - f"Db capability does not permit exec: {path}", - ) + _write_deny(caller, ret_area, db._deny(path, "exec")) except (sqlite3.Error, OSError, ValueError) as e: _write_result_err_ioerror( caller, ret_area, "SQLite exec failed", str(e), @@ -2431,10 +2432,7 @@ def db_query( if db is None: return if not db.allows(path): - _write_result_err_ioerror( - caller, ret_area, - f"Db capability does not permit query: {path}", - ) + _write_deny(caller, ret_area, db._deny(path, "query")) return try: conn = db._connect_verified(path) @@ -2458,10 +2456,7 @@ def db_query( s_ptr, s_len = _alloc_utf8(caller, payload) _write_result_ok_string(caller, ret_area, s_ptr, s_len) except PostOpenDenied: - _write_result_err_ioerror( - caller, ret_area, - f"Db capability does not permit query: {path}", - ) + _write_deny(caller, ret_area, db._deny(path, "query")) except (sqlite3.Error, OSError, ValueError) as e: _write_result_err_ioerror( caller, ret_area, "SQLite query failed", str(e), @@ -2628,6 +2623,14 @@ def _write_result_err_ioerror(caller, ret_area, message, cause=""): caller, c_len.to_bytes(4, "little"), ret_area + 16, ) + def _write_deny(caller, ret_area, err): + # Render a shared Proc._deny() result into the Err arm so the + # deny message + restriction cause match the Python backend. + io = err.error + _write_result_err_ioerror( + caller, ret_area, io.message, io.cause, + ) + def _lookup_proc_or_err(caller, handle, ret_area): """Resolve the receiver Proc cap. On failure write an Err(IoError) into ``ret_area`` and return None.""" @@ -2665,10 +2668,7 @@ def proc_exec( if proc is None: return if not proc.allows(cmd): - _write_result_err_ioerror( - caller, ret_area, - f"Proc capability does not permit exec: {cmd}", - ) + _write_deny(caller, ret_area, proc._deny(cmd)) return try: tail = json.loads(args_json) diff --git a/tests/test_ir_wasm_parity.py b/tests/test_ir_wasm_parity.py index c36e4c8d..1856a2ef 100644 --- a/tests/test_ir_wasm_parity.py +++ b/tests/test_ir_wasm_parity.py @@ -24,8 +24,10 @@ from __future__ import annotations import io +import os import shutil import sys +import tempfile import unittest from pathlib import Path @@ -5217,6 +5219,113 @@ def test_parse_float_without_any_float_format(self): ) +@unittest.skipUnless( + _has_wasm_tools() and _has_wasmtime_py(), + "wasm-tools and/or wasmtime-py not installed", +) +class TestCapErrorMessageParity(unittest.TestCase): + """Cross-backend Err-payload parity for Fs/Db/Proc failures. + + The Python runtime wraps an OSError as ``failed to ''`` + with the raw errno text as the cause, and routes a permission deny + through ``._deny`` (which names the op, the path, and the + current restriction). The two Wasm hosts used to drop the wrap + (bare errno text) and emit a terser deny with no restriction cause. + This sweep runs the SAME program on the Python backend, the core + ``WasmHost``, and the Component Model ``WasmComponentHost`` and + asserts all three print the identical Err payload. The errno text is + OS-specific, so the comparison is backend-to-backend rather than + against a golden string. + + The ``--wasi`` compiled path is intentionally NOT covered: it emits + a fixed path-less message by documented contract (its parity test is + discriminant-only, in test_wasi_mode.py). + """ + + def _probe(self, template: str, path: str) -> str: + # Forward slashes work on every OS and avoid escaping a + # backslash into the Capa string literal. + return template.replace("PATH", path.replace("\\", "/")) + + def _assert_all_backends_agree(self, src: str, label: str) -> None: + py = _capture_stdout(lambda: _run_python(src)) + core = _capture_stdout(lambda: _run_wasm(src)) + cm = _capture_stdout(lambda: _run_wasm_component(src)) + self.assertEqual( + py, core, + msg=f"{label}: Python vs core WasmHost diverge\n{src}", + ) + self.assertEqual( + py, cm, + msg=f"{label}: Python vs WasmComponentHost diverge\n{src}", + ) + + def test_fs_oserror_wrap_matches(self): + with tempfile.TemporaryDirectory() as d: + missing = os.path.join(d, "no_such_file.txt") + missing_dir = os.path.join(d, "no_such_dir") + write_into_missing = os.path.join(d, "no_such_dir", "f.txt") + a_file = os.path.join(d, "a_file") + with open(a_file, "w", encoding="utf-8") as fh: + fh.write("x") + # makedirs fails because a path component is a regular file. + mkdir_under_file = os.path.join(a_file, "sub") + cases = [ + ("fs.read", self._probe( + "fun main(fs: Fs, stdio: Stdio)\n" + ' match fs.read("PATH")\n' + ' Ok(x) -> stdio.println("ok")\n' + ' Err(e) -> stdio.println("${e}")\n', + missing)), + ("fs.write", self._probe( + "fun main(fs: Fs, stdio: Stdio)\n" + ' match fs.write("PATH", "data")\n' + ' Ok(x) -> stdio.println("ok")\n' + ' Err(e) -> stdio.println("${e}")\n', + write_into_missing)), + ("fs.mkdir", self._probe( + "fun main(fs: Fs, stdio: Stdio)\n" + ' match fs.mkdir("PATH")\n' + ' Ok(x) -> stdio.println("ok")\n' + ' Err(e) -> stdio.println("${e}")\n', + mkdir_under_file)), + ("fs.list_dir", self._probe( + "fun main(fs: Fs, stdio: Stdio)\n" + ' match fs.list_dir("PATH")\n' + ' Ok(x) -> stdio.println("ok")\n' + ' Err(e) -> stdio.println("${e}")\n', + missing_dir)), + ] + for label, src in cases: + with self.subTest(op=label): + self._assert_all_backends_agree(src, label) + + def test_class_b_deny_matches(self): + cases = [ + ("fs.read deny", + "fun main(fs: Fs, stdio: Stdio)\n" + ' let scoped = fs.restrict_to("allowed")\n' + ' match scoped.read("outside.txt")\n' + ' Ok(x) -> stdio.println("ok")\n' + ' Err(e) -> stdio.println("${e}")\n'), + ("db.exec deny", + "fun main(db: Db, stdio: Stdio)\n" + ' let scoped = db.restrict_to("allowed")\n' + ' match scoped.exec("other.db", "SELECT 1")\n' + ' Ok(x) -> stdio.println("ok")\n' + ' Err(e) -> stdio.println("${e}")\n'), + ("proc.exec deny", + "fun main(proc: Proc, stdio: Stdio)\n" + ' let scoped = proc.restrict_to("ls")\n' + ' match scoped.exec("rm", "[]")\n' + ' Ok(x) -> stdio.println("ok")\n' + ' Err(e) -> stdio.println("${e}")\n'), + ] + for label, src in cases: + with self.subTest(deny=label): + self._assert_all_backends_agree(src, label) + + @unittest.skipUnless( _has_wasm_tools() and _has_wasmtime_py(), "wasm-tools and/or wasmtime-py not installed", From f6900900354606beddb8f33c7ccd9d308ccb11a8 Mon Sep 17 00:00:00 2001 From: nelsonduarte Date: Fri, 17 Jul 2026 18:17:13 +0100 Subject: [PATCH 3/3] fix(wasm): resolve a payloadless-variant receiver/scrutinee to its sum A payloadless variant literal bound by an unannotated let/var (``let l = Leaf`` for ``type Tree = Leaf | Node(Int)``) is typed by the lowerer as the VARIANT name (``Leaf``), not the owning sum (``Tree``). The method-table, the multi-impl candidate table, and the sum-layout table are all keyed by the sum, so a method call (``l.val_of()``) raised "MethodCall on receiver of type 'Leaf'" and a match on the same binding raised "Match on scrutinee of type 'Leaf'". Both ran fine on the Python backend; only the Wasm emitter diverged. Resolve the variant head to its owning sum via ``_variant_to_sum`` at the consumer lookups, mirroring what _equality._normalize_eq_ty already does for ==: - _dispatch.py: normalise recv_head before the method-table / multi-impl routing gate. - _traits.py: normalise recv_ty in _emit_trait_method_call, which re-derives the receiver type and performs the authoritative lookup. - _match.py: normalise the sum head before the sum-layout lookup. The lowerer's ``.ty = variant-name`` is deliberately relied on as the payloadless-variant heap-pointer signal by the encoding / tuple / struct / value sites, so it is left intact; only the three lookups learn to fall back through _variant_to_sum. The variant-in-aggregate positions (variant in tuple / list / struct / Result, and equality) are unchanged and still pass. Tests: emitter-only cases in TestWasmEmissionShape (the method call and the match no longer raise; $Tree_val_of is emitted) and execution cases in TestWasmSumAndStruct for both the let and var forms. --- capa/ir/_emit_wasm/_dispatch.py | 7 +++ capa/ir/_emit_wasm/_match.py | 12 +++- capa/ir/_emit_wasm/_traits.py | 8 +++ tests/test_ir_wasm.py | 99 +++++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/capa/ir/_emit_wasm/_dispatch.py b/capa/ir/_emit_wasm/_dispatch.py index e84e1ed2..b7d5d20f 100644 --- a/capa/ir/_emit_wasm/_dispatch.py +++ b/capa/ir/_emit_wasm/_dispatch.py @@ -121,6 +121,13 @@ def _emit_instr(self, instr: Instr) -> None: # Reduce to the bare type name the method table is keyed by # (drops generic args and a typestate state index). recv_head = _strip_type_qualifiers(recv_ty) + # A payloadless variant literal (``let l = Leaf``) is typed + # by the lowerer as the VARIANT name, not the owning sum; the + # method table is keyed by the sum, so resolve it here + # (mirrors _equality._normalize_eq_ty). + if (recv_head not in self._sum_layouts + and recv_head in self._variant_to_sum): + recv_head = self._variant_to_sum[recv_head] if (recv_head, instr.method) in self._method_table: self._emit_trait_method_call(instr) return diff --git a/capa/ir/_emit_wasm/_match.py b/capa/ir/_emit_wasm/_match.py index df9b4e0a..ca2b801a 100644 --- a/capa/ir/_emit_wasm/_match.py +++ b/capa/ir/_emit_wasm/_match.py @@ -133,7 +133,17 @@ def _emit_match(self, instr: Match) -> None: # Sum-layout lookups strip generic args: ``Option`` -> # ``Option``. The built-in Option / Result and user-defined # sums are all keyed by the bare type name. - sum_layout = self._sum_layouts.get(scrut_ty.split("<", 1)[0]) + sum_head = scrut_ty.split("<", 1)[0] + # A payloadless variant literal (``let l = Leaf``) is typed by + # the lowerer as the VARIANT name, not the owning sum; the + # sum-layout table is keyed by the sum, so resolve it here + # (mirrors _equality._normalize_eq_ty). A bare variant carries + # no generic args, so the whole scrutinee type becomes the sum. + if (sum_head not in self._sum_layouts + and sum_head in self._variant_to_sum): + sum_head = self._variant_to_sum[sum_head] + scrut_ty = sum_head + sum_layout = self._sum_layouts.get(sum_head) if sum_layout is None: raise WasmEmissionError( f"Match on scrutinee of type {scrut_ty!r}: only sum " diff --git a/capa/ir/_emit_wasm/_traits.py b/capa/ir/_emit_wasm/_traits.py index 144df6e1..403d4263 100644 --- a/capa/ir/_emit_wasm/_traits.py +++ b/capa/ir/_emit_wasm/_traits.py @@ -285,6 +285,14 @@ def _emit_trait_method_call(self, instr: MethodCall) -> None: calls resolve to the right concrete-type entry in the method table.""" recv_ty = _strip_type_qualifiers(self._effective_value_ty(instr.receiver)) + # A payloadless variant literal (``let l = Leaf``) is typed by + # the lowerer as the VARIANT name, not the owning sum; the + # method table and the multi-impl candidate table are both keyed + # by the sum, so resolve it here (mirrors + # _equality._normalize_eq_ty, which does the same for ==). + if (recv_ty not in self._sum_layouts + and recv_ty in self._variant_to_sum): + recv_ty = self._variant_to_sum[recv_ty] candidates = self._multi_impl_candidates.get((recv_ty, instr.method)) if candidates: # Multi-impl trait receiver: dynamic dispatch by type-id. diff --git a/tests/test_ir_wasm.py b/tests/test_ir_wasm.py index 3975cb20..eb8ca0a0 100644 --- a/tests/test_ir_wasm.py +++ b/tests/test_ir_wasm.py @@ -322,6 +322,46 @@ def drop_count(body: str) -> int: self.assertEqual(drop_count(" to_json(j)\n"), 2) self.assertEqual(drop_count(" j.is_null()\n"), 1) + def test_method_call_on_payloadless_variant_receiver_emits(self): + # An unannotated ``let l = Leaf`` is typed as the VARIANT name + # (``Leaf``), not the sum (``Tree``); the method-table lookup is + # keyed by the sum, so the emitter must resolve the variant head + # to its owning sum. Before the fix this raised + # "MethodCall on receiver of type 'Leaf'". + src = ( + "type Tree =\n" + " Leaf\n" + " Node(Int)\n" + "impl Tree\n" + " fun val_of(self) -> Int\n" + " return match self\n" + " Leaf -> 0\n" + " Node(n) -> n\n" + "fun f() -> Int\n" + " let l = Leaf\n" + " return l.val_of()\n" + ) + ir_mod, _, _ = _parse_lower(src) + wat = emit_wat(ir_mod) # must not raise + self.assertIn("call $Tree_val_of", wat) + + def test_match_on_payloadless_variant_scrutinee_emits(self): + # Sibling of the method-call case: a match on the same binding + # used to raise "Match on scrutinee of type 'Leaf'". + src = ( + "type Tree =\n" + " Leaf\n" + " Node(Int)\n" + "fun g() -> Int\n" + " let l = Leaf\n" + " return match l\n" + " Leaf -> 0\n" + " Node(n) -> n\n" + ) + ir_mod, _, _ = _parse_lower(src) + wat = emit_wat(ir_mod) # must not raise + self.assertIn('(func $g (export "g")', wat) + @unittest.skipUnless(_has_wasm_tools(), "wasm-tools CLI not installed") class TestWasmAssembles(unittest.TestCase): @@ -1830,6 +1870,65 @@ def test_struct_allocator_advances_heap(self): self.assertNotEqual(p, q, "allocator must hand out distinct pointers") self.assertEqual(exp["diff"](store, p, q), 99) + # A payloadless variant literal bound by an UNANNOTATED let/var is + # typed by the lowerer as the VARIANT name (``Leaf``), not the owning + # sum (``Tree``). The method-table and sum-layout lookups are keyed + # by the sum, so both a method call and a match on that binding used + # to raise on the Wasm backend. They now resolve through + # ``_variant_to_sum`` at the consumer sites. + _TREE_IMPL = ( + "type Tree =\n" + " Leaf\n" + " Node(Int)\n" + "impl Tree\n" + " fun val_of(self) -> Int\n" + " return match self\n" + " Leaf -> 0\n" + " Node(n) -> n\n" + ) + + def test_method_call_on_unannotated_payloadless_variant_let(self): + src = self._TREE_IMPL + ( + "fun f() -> Int\n" + " let l = Leaf\n" + " return l.val_of()\n" + ) + self.assertEqual(self._exec(src, "f"), 0) + + def test_method_call_on_unannotated_payloadless_variant_var(self): + src = self._TREE_IMPL + ( + "fun f() -> Int\n" + " var l = Leaf\n" + " return l.val_of()\n" + ) + self.assertEqual(self._exec(src, "f"), 0) + + def test_match_on_unannotated_payloadless_variant_let(self): + src = ( + "type Tree =\n" + " Leaf\n" + " Node(Int)\n" + "fun g() -> Int\n" + " let l = Leaf\n" + " return match l\n" + " Leaf -> 0\n" + " Node(n) -> n\n" + ) + self.assertEqual(self._exec(src, "g"), 0) + + def test_match_on_unannotated_payloadless_variant_var(self): + src = ( + "type Tree =\n" + " Leaf\n" + " Node(Int)\n" + "fun g() -> Int\n" + " var l = Leaf\n" + " return match l\n" + " Leaf -> 0\n" + " Node(n) -> n\n" + ) + self.assertEqual(self._exec(src, "g"), 0) + @unittest.skipUnless( _has_wasm_tools() and _has_wasmtime_py(),