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
5 changes: 5 additions & 0 deletions capa/ir/_emit_wasm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions capa/ir/_emit_wasm/_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion capa/ir/_emit_wasm/_grisu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
"""
Expand Down
12 changes: 11 additions & 1 deletion capa/ir/_emit_wasm/_match.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,17 @@ def _emit_match(self, instr: Match) -> None:
# Sum-layout lookups strip generic args: ``Option<Int>`` ->
# ``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 "
Expand Down
8 changes: 8 additions & 0 deletions capa/ir/_emit_wasm/_traits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
60 changes: 23 additions & 37 deletions capa/runtime/_wasm_component_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -792,22 +796,18 @@ 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:
with fs._open_write(path) as f:
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:
Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -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:
Expand All @@ -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),
Expand Down Expand Up @@ -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:
Expand Down
Loading