diff --git a/capa/ir/_emit_wasm/__init__.py b/capa/ir/_emit_wasm/__init__.py index c49c32e..0b3512c 100644 --- a/capa/ir/_emit_wasm/__init__.py +++ b/capa/ir/_emit_wasm/__init__.py @@ -1704,10 +1704,17 @@ def _emit_try_unwrap(self, instr: TryUnwrap) -> None: self._write("i32.wrap_i64") self._write(f"local.set ${instr.dst}_len") return - head_dst = dst_ty.split("<", 1)[0] if dst_ty else "" - if head_dst in self._struct_layouts or head_dst in self._sum_layouts \ - or (dst_ty and dst_ty.startswith(("List", "Map", "Set"))): - # Pointer-shaped payload stored as i64.extend; unpack. + if dst_ty and ( + self._is_pointer_shape_ty(dst_ty) + or dst_ty in self._variant_to_sum + ): + # Pointer-shaped payload (struct / sum / List / Map / Set / + # tuple / trait value) stored as i64.extend; unpack back to + # the i32 pointer. The tuple case matters for a destructured + # ``let (m, s) = f()?`` whose element is itself pointer- + # shaped (Map / List / Set): the payload local is i32, so + # loading the slot as a bare i64 without the wrap would + # mismatch the Wasm validator. self._write("local.get $_m_scrut") self._write("i64.load offset=8") self._write("i32.wrap_i64") diff --git a/capa/ir/_emit_wasm/_tuples.py b/capa/ir/_emit_wasm/_tuples.py index 9bae115..aaa12aa 100644 --- a/capa/ir/_emit_wasm/_tuples.py +++ b/capa/ir/_emit_wasm/_tuples.py @@ -34,6 +34,14 @@ from ._layout import WasmEmissionError +def _is_unknown_slot_ty(ty: str) -> bool: + """True iff ``ty`` is an unresolved placeholder (empty, ``Unknown``, + ``Any``, or an analyzer tyvar ``?...``). Such a type carries no + shape information, so the emitter cannot pick the correct slot + encoding from it alone.""" + return ty in ("", "Unknown", "Any") or ty.startswith("?") + + def _tuple_arity(tuple_ty: str) -> int: """Count elements in a tuple type string. ``(A, B)`` -> 2. Returns 0 when the string isn't tuple-shaped.""" @@ -104,7 +112,24 @@ def _store_tuple_slot(self, v: Value, ty: str, offset: int) -> None: self._write("i64.extend_i32_u") self._write(f"i64.store offset={offset}") return - # Int / Unknown -> i64 store. + # Int -> i64 store. Fail-loud guard (defense in depth): a + # pointer-shaped VALUE reaching an unresolved slot type is a + # compiler type-propagation gap. The value is an i32 heap + # pointer, so sizing the slot as a bare i64 would ship invalid + # Wasm (an operand-stack type mismatch at the validator). The + # guard only fires when the value itself is pointer-shaped, so + # a legitimately i64-typed Int element never trips it. + if _is_unknown_slot_ty(ty) and ( + self._is_pointer_shape_ty(v.ty or "") + or (v.ty or "") in self._variant_to_sum + ): + raise WasmEmissionError( + f"tuple element at offset {offset} has an unresolved " + f"slot type {ty!r} but its value is pointer-shaped " + f"(value type {v.ty!r}); the slot would be mis-sized " + f"as i64. This is a compiler type-propagation gap, " + f"not a source error." + ) self._push_value(v) self._write(f"i64.store offset={offset}") @@ -160,6 +185,24 @@ def _emit_tuple_index(self, instr: Index) -> None: self._write("i32.wrap_i64") self._write(f"local.set ${instr.dst}") return - # Int / Unknown -> direct i64.load. + # Int -> direct i64.load. Fail-loud guard (defense in depth): + # when the dst binder type is unresolved we recover the slot's + # real type from the receiver tuple's element list. If that + # element is pointer-shaped the binder would be read as a bare + # i64 and stored into an i32 local, shipping invalid Wasm. + # Fail loud on that type-propagation gap; a genuine Int element + # (non-pointer) still falls through to the i64.load below. + if _is_unknown_slot_ty(dst_ty): + recv_elems = _tuple_elem_types(instr.receiver.ty or "") + slot_ty = recv_elems[idx] if idx < len(recv_elems) else "" + if self._is_pointer_shape_ty(slot_ty) \ + or slot_ty in self._variant_to_sum: + raise WasmEmissionError( + f"tuple index {idx} binds an unresolved dst type " + f"{dst_ty!r} but the element is pointer-shaped " + f"(slot type {slot_ty!r}); reading it as an i64 " + f"would ship invalid Wasm. This is a compiler " + f"type-propagation gap, not a source error." + ) self._write(f"i64.load offset={offset}") self._write(f"local.set ${instr.dst}") diff --git a/capa/ir/_lower_expr.py b/capa/ir/_lower_expr.py index 44b94ab..3fbcb0f 100644 --- a/capa/ir/_lower_expr.py +++ b/capa/ir/_lower_expr.py @@ -17,7 +17,9 @@ from .. import capa_ast as A from ._capa_types import BUILTIN_CAPS -from ._lower_helpers import _type_name, _ty_to_str, UnsupportedInIR +from ._lower_helpers import ( + _type_name, _ty_to_str, _unwrap_try_payload_ty, UnsupportedInIR, +) from ._nodes import ( AssignConst, BinOp, Call, FieldAccess, FormatStr, If, Index, MakeLambda, MakeList, MakeMap, MakeRange, MakeSet, MakeStruct, MakeTuple, Match, @@ -382,14 +384,24 @@ def _lower_try(self, e: A.Try) -> Value: # isinstance / is-None_ check + early return; no exception # path is involved. inner = self._lower_expr(e.expr) - # The unwrapped type is the inner's type-arg if known; - # without precise inference here we settle for Unknown and - # let the emitter rely on duck-typing. + # The unwrapped type is the inner's type-arg if known. result_ty = "Unknown" if self.types: t = self.types.get(id(e)) if t is not None: result_ty = _ty_to_str(t) + # When the analyzer left the ``Try`` node untyped, recover the + # payload from the operand's ``Result`` / ``Option`` + # type by stripping the Ok / Some arm. Otherwise the payload + # defaults to ``Unknown``, which flows into ``_lower_let`` and + # makes a destructured ``let (m, s) = f()?`` binder lose its + # element type; the Wasm tuple emitter then sizes a pointer- + # shaped element (Map / List / Set) as an i64 slot and the + # module fails the Wasm validator. + if result_ty in ("Unknown", "") or result_ty.startswith("?"): + recovered = _unwrap_try_payload_ty(inner.ty or "") + if recovered: + result_ty = recovered dst = fresh_local(self._counter) self._locals[dst] = result_ty self._instrs.append(TryUnwrap(dst=dst, src=inner)) diff --git a/capa/ir/_lower_helpers.py b/capa/ir/_lower_helpers.py index 55fee48..85b98df 100644 --- a/capa/ir/_lower_helpers.py +++ b/capa/ir/_lower_helpers.py @@ -121,6 +121,31 @@ def _split_tuple_elem_types(ty: str) -> list[str]: return _split_top_level(inner) +def _unwrap_try_payload_ty(ty: str) -> str: + """Strip the ``Ok`` / ``Some`` arm from a ``Result`` / + ``Option`` type string and return the unwrapped payload ``T``. + Returns ``""`` when ``ty`` is not Result / Option shaped so the + caller keeps its own fallback. + + This lets ``_lower_try`` recover the precise ``?`` result type + from the operand's ``Result`` / ``Option`` type when the analyzer + left the ``Try`` node untyped. Without it the payload defaults to + ``Unknown``, which the Wasm tuple emitter then sizes as an i64 + slot even for a pointer-shaped element (Map / List / Set), so a + destructured ``let (m, s) = f()?`` binder loses its pointer type + and the module fails the Wasm validator.""" + head, sep, rest = ty.partition("<") + if not sep or not rest.endswith(">"): + return "" + inner = rest[:-1].strip() + if head == "Result": + payload, _err = _split_top_level_comma(inner) + return payload.strip() + if head == "Option": + return inner + return "" + + def _split_top_level_comma(s: str) -> tuple[str, str]: """Split ``"T, Map"`` into ``("T", "Map")`` by counting angle brackets AND parentheses so commas inside diff --git a/tests/test_ir_wasm.py b/tests/test_ir_wasm.py index 94e5764..ed728c0 100644 --- a/tests/test_ir_wasm.py +++ b/tests/test_ir_wasm.py @@ -1366,6 +1366,160 @@ def test_guard_over_tuple_payload(self): self.assertEqual(out, "pos 1 2\n") +@unittest.skipUnless( + _has_wasm_tools() and _has_wasmtime_py(), + "wasm-tools and/or wasmtime-py not installed", +) +class TestWasmTuplePointerElementThroughTry(unittest.TestCase): + """A tuple whose element is pointer-shaped (``Map`` / ``List`` / + ``Set``) returned through a ``?`` / ``Result`` boundary and then + destructured. + + Pre-fix ``_lower_try`` defaulted the ``?`` payload type to + ``Unknown`` when the analyzer left the ``Try`` node untyped; the + ``let (m, s) = f()?`` binders inherited it, and the Wasm tuple + emitter sized the ``Map`` element as an i64 slot even though a + ``Map`` is an i32 heap pointer. The module then failed the Wasm + validator (``expected i32, found i64``) despite passing ``--check`` + and running on the Python backend. Each case asserts byte-exact + stdout parity with the Python backend.""" + + def _run_capturing_stdout(self, src: str) -> tuple[str, str]: + import io + import sys + from capa.runtime._wasm_host import WasmHost + _, types, ast_mod = _parse_lower(src) + blob = compile_wasm(ast_mod, types=types) + host = WasmHost() + out, err = io.StringIO(), io.StringIO() + saved_out, saved_err = sys.stdout, sys.stderr + sys.stdout, sys.stderr = out, err + try: + host.run_main(blob) + finally: + sys.stdout, sys.stderr = saved_out, saved_err + return out.getvalue(), err.getvalue() + + def test_map_element_let_destructure_through_try(self): + src = ( + "type E =\n" + " Bad\n" + "fun build() -> Result<(Map, String), E>\n" + " let m: Map = new_map()\n" + ' m.set("k", "v")\n' + ' return Ok((m, "tail"))\n' + "fun run() -> Result\n" + " let (m, s) = build()?\n" + ' let got = match m.get("k")\n' + ' None -> "missing"\n' + " Some(v) -> v\n" + ' return Ok(got + "-" + s)\n' + "fun main(stdio: Stdio)\n" + " match run()\n" + " Ok(v) -> stdio.println(v)\n" + ' Err(_) -> stdio.println("err")\n' + ) + out, _ = self._run_capturing_stdout(src) + self.assertEqual(out, "v-tail\n") + + def test_map_element_match_ok_destructure(self): + src = ( + "type E =\n" + " Bad\n" + "fun build() -> Result<(Map, String), E>\n" + " let m: Map = new_map()\n" + ' m.set("k", "v")\n' + ' return Ok((m, "tail"))\n' + "fun run() -> String\n" + " match build()\n" + " Ok((m, s)) ->\n" + ' let got = match m.get("k")\n' + ' None -> "missing"\n' + " Some(v) -> v\n" + ' return got + "-" + s\n' + ' Err(_) -> return "err"\n' + "fun main(stdio: Stdio)\n" + " stdio.println(run())\n" + ) + out, _ = self._run_capturing_stdout(src) + self.assertEqual(out, "v-tail\n") + + def test_list_element_let_destructure_through_try(self): + src = ( + "type E =\n" + " Bad\n" + "fun build() -> Result<(List, String), E>\n" + " let xs: List = [1, 2, 3]\n" + ' return Ok((xs, "tail"))\n' + "fun run() -> Result\n" + " let (xs, s) = build()?\n" + ' return Ok("${xs.length()}-${s}")\n' + "fun main(stdio: Stdio)\n" + " match run()\n" + " Ok(v) -> stdio.println(v)\n" + ' Err(_) -> stdio.println("err")\n' + ) + out, _ = self._run_capturing_stdout(src) + self.assertEqual(out, "3-tail\n") + + +class TestWasmTuplePointerSlotGuard(unittest.TestCase): + """Fail-loud guard (defense in depth) for the tuple slot emitter. + A pointer-shaped value reaching an unresolved (``Unknown`` / tyvar) + slot type must raise a clear compiler diagnostic rather than fall + silently into the i64 branch and ship invalid Wasm. These are pure + emitter unit tests (no Wasm toolchain), so the class is not skip- + guarded: it must run on the plain ``test`` CI job too.""" + + def _emitter(self): + from capa.ir._emit_wasm import WasmEmitter + return WasmEmitter() + + def test_store_pointer_value_into_unknown_slot_raises(self): + from capa.ir._nodes import Value + em = self._emitter() + v = Value(kind="local", name="m", ty="Map") + with self.assertRaises(WasmEmissionError) as ctx: + em._store_tuple_slot(v, "Unknown", 0) + self.assertIn("pointer-shaped", str(ctx.exception)) + + def test_index_pointer_from_unknown_dst_raises(self): + from types import SimpleNamespace + from capa.ir._nodes import Value, Index + em = self._emitter() + em._current_fn = SimpleNamespace(locals={"m": "Unknown"}) + recv = Value( + kind="local", name="t", ty="(Map, String)", + ) + instr = Index( + dst="m", receiver=recv, + index=Value(kind="lit_int", literal=0), + ) + with self.assertRaises(WasmEmissionError) as ctx: + em._emit_tuple_index(instr) + self.assertIn("pointer-shaped", str(ctx.exception)) + + def test_store_int_value_into_unknown_slot_does_not_raise(self): + # Neighbour guard: a legitimately i64-typed element (Int) in an + # unresolved slot must NOT trip the guard. + from capa.ir._nodes import Value + em = self._emitter() + v = Value(kind="local", name="n", ty="Int") + em._store_tuple_slot(v, "Unknown", 0) # no raise + + def test_index_int_from_unknown_dst_does_not_raise(self): + from types import SimpleNamespace + from capa.ir._nodes import Value, Index + em = self._emitter() + em._current_fn = SimpleNamespace(locals={"n": "Unknown"}) + recv = Value(kind="local", name="t", ty="(Int, String)") + instr = Index( + dst="n", receiver=recv, + index=Value(kind="lit_int", literal=0), + ) + em._emit_tuple_index(instr) # no raise + + @unittest.skipUnless( _has_wasm_tools() and _has_wasmtime_py(), "wasm-tools and/or wasmtime-py not installed", diff --git a/tests/test_ir_wasm_parity.py b/tests/test_ir_wasm_parity.py index a580cfc..c7f99fa 100644 --- a/tests/test_ir_wasm_parity.py +++ b/tests/test_ir_wasm_parity.py @@ -5888,5 +5888,122 @@ def test_match_struct_pattern_still_works(self): self._assert_src_parity(src, expect="7\n") +@unittest.skipUnless( + _has_wasm_tools() and _has_wasmtime_py(), + "wasm-tools and/or wasmtime-py not installed", +) +class TestTuplePointerElementThroughTryParity(unittest.TestCase): + """Regression net for the silent Wasm miscompile where a tuple whose + element is pointer-shaped (``Map`` / ``List`` / ``Set``) is returned + through a ``?`` / ``Result`` boundary and destructured. + + Root cause: ``_lower_try`` defaulted the ``?`` result type to + ``Unknown`` when the analyzer left the ``Try`` node untyped. That + ``Unknown`` flowed into the ``let (m, s) = f()?`` binders, so the + Wasm tuple emitter sized the ``Map`` element as an i64 slot even + though a ``Map`` is an i32 heap pointer, and the module failed the + Wasm validator (``expected i32, found i64``). ``--check`` and the + Python backend were always correct; these assert byte-identical + output across both backends. The ``match Ok((m, s))`` form was + already saved by the pattern-bind refinement and is pinned here as a + neighbour.""" + + def _assert_src_parity(self, src: str, expect: str | None = None) -> None: + py_out = _capture_stdout(lambda: _run_python(src)) + wasm_out = _capture_stdout(lambda: _run_wasm(src)) + self.assertEqual( + py_out, wasm_out, + msg=( + f"Python/Wasm output divergence.\n" + f"--- python ---\n{py_out}\n" + f"--- wasm ---\n{wasm_out}" + ), + ) + if expect is not None: + self.assertEqual(py_out, expect) + + def test_map_element_let_destructure_through_try(self): + # The headline repro: ``let (m, s) = build()?`` where the tuple's + # first element is a Map. Pre-fix this shipped invalid Wasm. + src = ( + "type E =\n" + " Bad\n" + "fun build() -> Result<(Map, String), E>\n" + " let m: Map = new_map()\n" + ' m.set("k", "v")\n' + ' return Ok((m, "tail"))\n' + "fun run() -> Result\n" + " let (m, s) = build()?\n" + ' let got = match m.get("k")\n' + ' None -> "missing"\n' + " Some(v) -> v\n" + ' return Ok(got + "-" + s)\n' + "fun main(stdio: Stdio)\n" + " match run()\n" + " Ok(v) -> stdio.println(v)\n" + ' Err(_) -> stdio.println("err")\n' + ) + self._assert_src_parity(src, expect="v-tail\n") + + def test_map_element_match_ok_destructure(self): + # The ``match build() { Ok((m, s)) -> ... }`` form: already + # saved by the pattern-bind refinement, pinned as a neighbour. + src = ( + "type E =\n" + " Bad\n" + "fun build() -> Result<(Map, String), E>\n" + " let m: Map = new_map()\n" + ' m.set("k", "v")\n' + ' return Ok((m, "tail"))\n' + "fun run() -> String\n" + " match build()\n" + " Ok((m, s)) ->\n" + ' let got = match m.get("k")\n' + ' None -> "missing"\n' + " Some(v) -> v\n" + ' return got + "-" + s\n' + ' Err(_) -> return "err"\n' + "fun main(stdio: Stdio)\n" + " stdio.println(run())\n" + ) + self._assert_src_parity(src, expect="v-tail\n") + + def test_list_element_let_destructure_through_try(self): + # A List element in the pointer slot must decode too. + src = ( + "type E =\n" + " Bad\n" + "fun build() -> Result<(List, String), E>\n" + " let xs: List = [1, 2, 3]\n" + ' return Ok((xs, "tail"))\n' + "fun run() -> Result\n" + " let (xs, s) = build()?\n" + ' return Ok("${xs.length()}-${s}")\n' + "fun main(stdio: Stdio)\n" + " match run()\n" + " Ok(v) -> stdio.println(v)\n" + ' Err(_) -> stdio.println("err")\n' + ) + self._assert_src_parity(src, expect="3-tail\n") + + def test_scalar_tuple_element_through_try_unaffected(self): + # Neighbour guard: a scalar-only tuple element (Int) through + # ``?`` was always correct and must stay so. + src = ( + "type E =\n" + " Bad\n" + "fun build() -> Result<(Int, String), E>\n" + ' return Ok((7, "tail"))\n' + "fun run() -> Result\n" + " let (n, s) = build()?\n" + ' return Ok("${n}-${s}")\n' + "fun main(stdio: Stdio)\n" + " match run()\n" + " Ok(v) -> stdio.println(v)\n" + ' Err(_) -> stdio.println("err")\n' + ) + self._assert_src_parity(src, expect="7-tail\n") + + if __name__ == "__main__": unittest.main()