diff --git a/capa/ir/_emit_wasm/__init__.py b/capa/ir/_emit_wasm/__init__.py index 23b9459..a6f6572 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/_dispatch.py b/capa/ir/_emit_wasm/_dispatch.py index e84e1ed..b7d5d20 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/_grisu.py b/capa/ir/_emit_wasm/_grisu.py index 25bbe26..f673bdb 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/capa/ir/_emit_wasm/_match.py b/capa/ir/_emit_wasm/_match.py index df9b4e0..ca2b801 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 144df6e..403d426 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/capa/runtime/_wasm_component_host.py b/capa/runtime/_wasm_component_host.py index 8fe878f..f919ee6 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 dbe71c5..f34926e 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.py b/tests/test_ir_wasm.py index c6d44f0..eb8ca0a 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 @@ -321,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): @@ -1829,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(), @@ -9207,6 +9307,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 +9327,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 +9574,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 b995973..1856a2e 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 @@ -5185,6 +5187,144 @@ 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(), + "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(),