Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions capa/ir/_emit_wasm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
47 changes: 45 additions & 2 deletions capa/ir/_emit_wasm/_tuples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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}")

Expand Down Expand Up @@ -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}")
20 changes: 16 additions & 4 deletions capa/ir/_lower_expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<T, E>`` / ``Option<T>``
# 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))
Expand Down
25 changes: 25 additions & 0 deletions capa/ir/_lower_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<T, E>`` /
``Option<T>`` 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<K, V>"`` into ``("T", "Map<K, V>")`` by
counting angle brackets AND parentheses so commas inside
Expand Down
154 changes: 154 additions & 0 deletions tests/test_ir_wasm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, String>, String), E>\n"
" let m: Map<String, String> = new_map()\n"
' m.set("k", "v")\n'
' return Ok((m, "tail"))\n'
"fun run() -> Result<String, E>\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, String>, String), E>\n"
" let m: Map<String, String> = 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<Int>, String), E>\n"
" let xs: List<Int> = [1, 2, 3]\n"
' return Ok((xs, "tail"))\n'
"fun run() -> Result<String, E>\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<String, String>")
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, String>, 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",
Expand Down
Loading