diff --git a/capa/ir/_capa_types.py b/capa/ir/_capa_types.py index c4c641af..68e0c7b1 100644 --- a/capa/ir/_capa_types.py +++ b/capa/ir/_capa_types.py @@ -24,9 +24,42 @@ # their method calls to host imports (Wasm) or to capability # classes from ``capa.runtime`` (Python). The set must match # ``capa.typesys.CAPABILITY_NAMES`` and ``capa.builtins``'s -# registered capability classes; cross-check fires the -# manifest-vs-runtime property test in -# [`tests/test_properties.py`](../../tests/test_properties.py). +# registered capability classes; the cross-check that keeps the +# copies in lockstep is ``TestCapabilityRegistry`` in +# [`tests/test_cap_handles.py`](../../tests/test_cap_handles.py). +# (This comment used to promise ``tests/test_properties.py``, but +# that module skips wholesale when Hypothesis is absent and a +# registry guard must never be skippable.) BUILTIN_CAPS: frozenset[str] = frozenset({ "Stdio", "Fs", "Net", "Env", "Clock", "Random", "Proc", "Db", "Unsafe", }) + + +# The built-in caps that are UN-ERASED on the Wasm side: each one +# lowers to a real i32 handle into the host's per-instance cap +# table, so a restricted cap keeps its restriction across function +# boundaries (audit slices 25.2 - 25.6, 2026-05-30). A handle +# occupies a Wasm slot everywhere a value can live: params, locals, +# struct fields, closure environments, call args, return values, +# and ``main``'s exported signature. +# +# The complement (``ERASED_CAPS`` below) pushes no Wasm value at +# all: those caps have no attenuation surface to thread. +# +# Every built-in capability MUST appear in exactly one of the two +# sets; ``TestCapabilityRegistry`` in +# [`tests/test_cap_handles.py`](../../tests/test_cap_handles.py) +# fails if a new name lands in ``BUILTIN_CAPS`` without that +# decision being recorded here. +HANDLE_BEARING_CAPS: frozenset[str] = frozenset({ + "Fs", "Net", "Db", "Proc", "Env", "Clock", +}) + + +# The deliberately-erased complement. Spelled out rather than +# derived from ``BUILTIN_CAPS - HANDLE_BEARING_CAPS`` so that adding +# a capability forces an explicit choice instead of silently +# defaulting to "erased". +ERASED_CAPS: frozenset[str] = frozenset({ + "Stdio", "Random", "Unsafe", +}) diff --git a/capa/ir/_emit_wasm/__init__.py b/capa/ir/_emit_wasm/__init__.py index a6f6572f..666ce91e 100644 --- a/capa/ir/_emit_wasm/__init__.py +++ b/capa/ir/_emit_wasm/__init__.py @@ -47,7 +47,7 @@ # ``_WIT_SIGNATURES`` is consumed in ``_caps.py`` (sig dispatch) and # ``_discovery.py`` (early validation); not referenced directly # from this module any more after the mixin extraction. -from .._capa_types import BUILTIN_CAPS +from .._capa_types import BUILTIN_CAPS, HANDLE_BEARING_CAPS from .._walk import iter_functions, walk_module from ._layout import ( WasmEmissionError, @@ -1487,9 +1487,7 @@ def _emit_function(self, fn: Function) -> None: param_clauses = [] for p in fn.params: if p.ty in BUILTIN_CAPS: - if p.ty in ( - "Fs", "Net", "Db", "Proc", "Env", "Clock", - ): + if p.ty in HANDLE_BEARING_CAPS: param_clauses.append(f"(param ${p.name} i32)") continue if p.ty == "String": @@ -1641,7 +1639,7 @@ def _push_call_args(self, args: list) -> None: (``_emit_trait_method_call``) so the three never drift.""" for arg in args: if arg.ty in BUILTIN_CAPS: - if arg.ty in ("Fs", "Net", "Db", "Proc", "Env", "Clock"): + if arg.ty in HANDLE_BEARING_CAPS: self._push_value(arg) continue if arg.ty == "String": @@ -2340,9 +2338,7 @@ def _emit_user_call(self, instr: Call) -> None: if dst_ty == "String": self._write(f"local.set ${instr.dst}_len") self._write(f"local.set ${instr.dst}_ptr") - elif dst_ty in ( - "Fs", "Net", "Db", "Proc", "Env", "Clock", - ): + elif dst_ty in HANDLE_BEARING_CAPS: # Slices 25.2 - 25.6: Fs / Net / Db / Proc / Env / # Clock return values carry the handle as i32. self._write(f"local.set ${instr.dst}") diff --git a/capa/ir/_emit_wasm/_caps.py b/capa/ir/_emit_wasm/_caps.py index 65070b31..ab4bf3bf 100644 --- a/capa/ir/_emit_wasm/_caps.py +++ b/capa/ir/_emit_wasm/_caps.py @@ -424,6 +424,13 @@ def _emit_cap_method_call(self, instr: MethodCall) -> None: # enforcement (the old guest-side lexical prefix check could # not realpath). ``Clock.allows`` was already host-side (it # takes no string arg) - see the branch below. + # + # Registry note: this is ``HANDLE_BEARING_CAPS`` minus Clock, + # and it legitimately differs - the set here is "handle- + # bearing caps whose ``allows`` takes a STRING argument", so + # Clock (whose ``allows`` is nullary) needs its own emitter. + # Kept as a literal so a future cap must be classified by + # hand rather than inherited by set arithmetic. if method == "allows" and cap in ("Fs", "Env", "Db", "Proc", "Net"): self._emit_cap_allows_with_handle(instr, cap) return @@ -518,6 +525,12 @@ def _emit_cap_method_call(self, instr: MethodCall) -> None: # passing helper. The host bridge looks up the receiver cap # from the table and enforces ``cap.allows(arg)`` before # the syscall, closing audit slice 25 F1 for these caps. + # + # Registry note: legitimately NOT ``HANDLE_BEARING_CAPS``. + # This is the GENERIC indirect-return path; the other three + # handle-bearing caps are dispatched before reaching here - + # Fs and Net by dedicated emitters above (Net also has WASI + # variants), Clock by the primitive-return branch below. if cap in ("Db", "Proc", "Env") and indirect is not None: self._emit_indirect_with_cap_handle(instr, cap, method, indirect) return diff --git a/capa/ir/_emit_wasm/_closures.py b/capa/ir/_emit_wasm/_closures.py index a26db7ab..d5f90b8f 100644 --- a/capa/ir/_emit_wasm/_closures.py +++ b/capa/ir/_emit_wasm/_closures.py @@ -32,7 +32,7 @@ If, While, For, Match, FormatStr, Pattern, PatIdent, PatVariant, ) -from .._capa_types import BUILTIN_CAPS +from .._capa_types import BUILTIN_CAPS, HANDLE_BEARING_CAPS from .._walk import walk_instrs from ._layout import ( WasmEmissionError, @@ -128,9 +128,7 @@ def visit_value(v) -> None: p.ty for p in target.params if ( p.ty not in BUILTIN_CAPS - or p.ty in ( - "Fs", "Net", "Db", "Proc", "Env", "Clock", - ) + or p.ty in HANDLE_BEARING_CAPS ) ] ret_capa_ty = target.return_type or "Unit" @@ -274,9 +272,7 @@ def _emit_fn_ref_thunk(self, thunk: dict) -> None: # Clock are un-erased - the closure ABI must thread # their i32 handles the same way it threads any # other scalar. - if p.ty in ( - "Fs", "Net", "Db", "Proc", "Env", "Clock", - ): + if p.ty in HANDLE_BEARING_CAPS: param_clauses.append(f"(param ${p.name} i32)") continue if p.ty == "String": @@ -298,9 +294,7 @@ def _emit_fn_ref_thunk(self, thunk: dict) -> None: # is intentionally ignored: thunks have no captured state. for p in params: if p.ty in BUILTIN_CAPS: - if p.ty in ( - "Fs", "Net", "Db", "Proc", "Env", "Clock", - ): + if p.ty in HANDLE_BEARING_CAPS: self._write(f"local.get ${p.name}") continue if p.ty == "String": @@ -663,15 +657,11 @@ def nested_defs(ins: list[Instr]) -> None: # see whatever default the host bridge invented, # defeating the cross-function soundness these # slices deliver). - if capa_ty not in ( - "Fs", "Net", "Db", "Proc", "Env", "Clock", - ): + if capa_ty not in HANDLE_BEARING_CAPS: continue size = ( self._size_of(capa_ty) - if capa_ty not in ( - "Fs", "Net", "Db", "Proc", "Env", "Clock", - ) else 4 + if capa_ty not in HANDLE_BEARING_CAPS else 4 ) offset = _align_up(offset, size) env_layout[name] = (offset, capa_ty) @@ -952,9 +942,7 @@ def _emit_closure_call(self, instr: Call, callee_ty: str) -> None: # handles; other built-in caps stay erased. for arg in instr.args: if arg.ty in BUILTIN_CAPS: - if arg.ty in ( - "Fs", "Net", "Db", "Proc", "Env", "Clock", - ): + if arg.ty in HANDLE_BEARING_CAPS: self._push_value(arg) continue if arg.ty == "String": @@ -973,9 +961,7 @@ def _emit_closure_call(self, instr: Call, callee_ty: str) -> None: dst_ty and ( dst_ty not in BUILTIN_CAPS - or dst_ty in ( - "Fs", "Net", "Db", "Proc", "Env", "Clock", - ) + or dst_ty in HANDLE_BEARING_CAPS ) and dst_ty not in ("Unit",) ): diff --git a/capa/ir/_emit_wasm/_discovery.py b/capa/ir/_emit_wasm/_discovery.py index 586e7f1d..3b3766d0 100644 --- a/capa/ir/_emit_wasm/_discovery.py +++ b/capa/ir/_emit_wasm/_discovery.py @@ -606,6 +606,13 @@ def _discover_instrs(self, instrs: list[Instr]) -> None: # and return a fresh i32 handle bound to a narrower # restriction. Register the imports so the linker # resolves the host callbacks. + # + # Registry note: the three branches below TOGETHER + # cover exactly ``HANDLE_BEARING_CAPS``; the split is + # by ATTENUATOR METHOD NAME, not by cap class - Env + # narrows via ``restrict_to_keys`` and Clock via + # ``restrict_to_after``, so only the remaining four + # share the ``restrict_to`` spelling. if (cap in ("Fs", "Net", "Db", "Proc") and instr.method == "restrict_to"): self._used_caps.add((cap, "restrict_to")) @@ -621,6 +628,12 @@ def _discover_instrs(self, instrs: list[Instr]) -> None: pass elif (cap in ("Fs", "Env", "Db", "Proc", "Net") and instr.method == "allows"): + # Registry note: ``HANDLE_BEARING_CAPS`` minus + # Clock, mirroring the emitter split in + # ``_caps._emit_cap_method_call`` - these five + # take a string arg, ``Clock.allows`` is nullary + # and already had its own host route. + # # GAP-2b (2026-06-21): Fs.allows / Env.allows / # Db.allows / Proc.allows / Net.allows route # through the authoritative host function diff --git a/capa/ir/_emit_wasm/_dispatch.py b/capa/ir/_emit_wasm/_dispatch.py index b7d5d20f..1f797c90 100644 --- a/capa/ir/_emit_wasm/_dispatch.py +++ b/capa/ir/_emit_wasm/_dispatch.py @@ -16,7 +16,7 @@ from __future__ import annotations -from .._capa_types import BUILTIN_CAPS +from .._capa_types import BUILTIN_CAPS, HANDLE_BEARING_CAPS from .._nodes import ( AssignConst, Reassign, BinOp, UnaryOp, Call, MethodCall, If, While, Break, Continue, Return, TryUnwrap, @@ -37,9 +37,7 @@ def _emit_instr(self, instr: Instr) -> None: # (slices 25.2 - 25.6) which carry i32 handles so a # restricted cap survives crossing function # boundaries. - if dst_ty in ( - "Fs", "Net", "Db", "Proc", "Env", "Clock", - ): + if dst_ty in HANDLE_BEARING_CAPS: self._push_value(instr.src) self._write(f"local.set ${instr.dst}") return @@ -59,9 +57,7 @@ def _emit_instr(self, instr: Instr) -> None: if isinstance(instr, Reassign): dst_ty = self._dst_capa_ty(instr.dst) if dst_ty in BUILTIN_CAPS: - if dst_ty in ( - "Fs", "Net", "Db", "Proc", "Env", "Clock", - ): + if dst_ty in HANDLE_BEARING_CAPS: self._push_value(instr.src) self._write(f"local.set ${instr.dst}") return diff --git a/capa/ir/_emit_wasm/_locals.py b/capa/ir/_emit_wasm/_locals.py index e8e8318f..5caa7098 100644 --- a/capa/ir/_emit_wasm/_locals.py +++ b/capa/ir/_emit_wasm/_locals.py @@ -29,7 +29,7 @@ PatIdent, PatVariant, PatTuple, ) from .._lower_pattern import PatStruct -from .._capa_types import BUILTIN_CAPS +from .._capa_types import BUILTIN_CAPS, HANDLE_BEARING_CAPS from ._layout import ( _element_type_of_list, _element_type_of_set, _map_key_type, WasmEmissionError, @@ -746,9 +746,7 @@ def visit(instrs: list[Instr]) -> None: # so a restricted cap survives crossing function # boundaries. if capa_ty in BUILTIN_CAPS: - if capa_ty in ( - "Fs", "Net", "Db", "Proc", "Env", "Clock", - ): + if capa_ty in HANDLE_BEARING_CAPS: out[dst] = "i32" continue # String locals expand to a (ptr, len) pair so @@ -796,9 +794,7 @@ def visit(instrs: list[Instr]) -> None: # Slices 25.2 - 25.6 (2026-05-30): Fs / Net / Db / # Proc / Env / Clock are un-erased; every other cap # stays as a no-Wasm-value. - if capa_ty in ( - "Fs", "Net", "Db", "Proc", "Env", "Clock", - ): + if capa_ty in HANDLE_BEARING_CAPS: out[name] = "i32" continue if capa_ty == "String": diff --git a/capa/ir/_emit_wasm/_structs.py b/capa/ir/_emit_wasm/_structs.py index a893d977..ae7b7dfa 100644 --- a/capa/ir/_emit_wasm/_structs.py +++ b/capa/ir/_emit_wasm/_structs.py @@ -26,7 +26,7 @@ from .._nodes import ( Call, FieldAccess, FieldStore, MakeStruct, Value, ) -from .._capa_types import BUILTIN_CAPS +from .._capa_types import BUILTIN_CAPS, HANDLE_BEARING_CAPS from ._layout import ( WasmEmissionError, _store_op_for_size, _load_op_for_size, _strip_type_qualifiers, _TYPE_ID_OFFSET, @@ -325,9 +325,7 @@ def _emit_make_struct(self, instr: MakeStruct) -> None: # Proc / Env / Clock become i32 handles the struct # must carry so a restricted cap stashed in a record # survives across function boundaries. - if field_ty in ( - "Fs", "Net", "Db", "Proc", "Env", "Clock", - ): + if field_ty in HANDLE_BEARING_CAPS: self._write(f"local.get ${instr.dst}") self._push_value(fval) self._write(f"i32.store offset={offset}") @@ -388,9 +386,7 @@ def _emit_field_access(self, instr: FieldAccess) -> None: # as the receiver of subsequent privileged calls # (closing audit slice 25 F1 for cap values stashed in # records). - if field_ty in ( - "Fs", "Net", "Db", "Proc", "Env", "Clock", - ): + if field_ty in HANDLE_BEARING_CAPS: self._push_value(instr.receiver) self._write(f"i32.load offset={offset}") self._write(f"local.set ${instr.dst}") @@ -442,7 +438,7 @@ def _emit_field_store(self, instr: FieldStore) -> None: # carry i32 handles (slices 25.2 - 25.6); store the # handle. Every other cap is erased and has no Wasm value, # so the store is a no-op (matching _emit_make_struct). - if field_ty in ("Fs", "Net", "Db", "Proc", "Env", "Clock"): + if field_ty in HANDLE_BEARING_CAPS: self._push_value(instr.receiver) self._push_value(instr.src) self._write(f"i32.store offset={offset}") diff --git a/capa/ir/_emit_wasm/_traits.py b/capa/ir/_emit_wasm/_traits.py index 403d4263..f941eb40 100644 --- a/capa/ir/_emit_wasm/_traits.py +++ b/capa/ir/_emit_wasm/_traits.py @@ -33,7 +33,7 @@ from __future__ import annotations from .._nodes import Function, MethodCall -from .._capa_types import BUILTIN_CAPS +from .._capa_types import BUILTIN_CAPS, HANDLE_BEARING_CAPS from ._layout import ( WasmEmissionError, _strip_type_qualifiers, _TYPE_ID_OFFSET, ) @@ -401,9 +401,7 @@ def _store_trait_call_result(self, instr: MethodCall) -> None: # Multi-value (i32 i32) return -> dst pair. self._write(f"local.set ${instr.dst}_len") self._write(f"local.set ${instr.dst}_ptr") - elif dst_ty in ( - "Fs", "Net", "Db", "Proc", "Env", "Clock", - ): + elif dst_ty in HANDLE_BEARING_CAPS: # Slices 25.2 - 25.6: Fs / Net / Db / Proc / Env / # Clock return the handle as i32. self._write(f"local.set ${instr.dst}") diff --git a/capa/ir/_emit_wasm/_values.py b/capa/ir/_emit_wasm/_values.py index 698e920b..5cd72595 100644 --- a/capa/ir/_emit_wasm/_values.py +++ b/capa/ir/_emit_wasm/_values.py @@ -28,7 +28,7 @@ from typing import Optional from .._nodes import Value -from .._capa_types import BUILTIN_CAPS +from .._capa_types import BUILTIN_CAPS, HANDLE_BEARING_CAPS from ._layout import ( WasmEmissionError, _TYPE_SIZE, _size_of, _store_op_for_size, _load_op_for_size, @@ -118,7 +118,7 @@ def _call_result_slot_count(self, ret_ty: str) -> int: return 0 if head == "String": return 2 - if head in ("Fs", "Net", "Db", "Proc", "Env", "Clock"): + if head in HANDLE_BEARING_CAPS: return 1 if head in BUILTIN_CAPS: # Non-handle capabilities (e.g. Stdio) are erased at the @@ -357,7 +357,7 @@ def _wasm_type(self, capa_ty: str) -> str: # by routing through ``urlparse(url).hostname`` rather than # ``$str_contains``). Random / Unsafe / Stdio stay erased # (no attenuation surface to wire). - if head in ("Fs", "Net", "Db", "Proc", "Env", "Clock"): + if head in HANDLE_BEARING_CAPS: return "i32" # ``()`` is Capa's empty-tuple / Unit alias from the type # printer; treat it the same as Unit (no Wasm result). diff --git a/capa/ir/_emit_wit.py b/capa/ir/_emit_wit.py index 7847ea28..8cd7f55e 100644 --- a/capa/ir/_emit_wit.py +++ b/capa/ir/_emit_wit.py @@ -275,6 +275,20 @@ # in ``_WIT_SIGNATURES`` raise ``UnsupportedCapability`` at WIT # generation time; the Wasm emitter mirrors this so the contract # stays in sync. +# +# This is ``BUILTIN_CAPS`` MINUS ``Unsafe``, and the omission is +# deliberate, not drift: ``Unsafe`` is the Python-only FFI escape +# hatch and can never reach WIT generation. The Wasm emitter +# rejects an ``Unsafe`` anywhere in a signature up front (see +# ``_discovery._reject_unsafe_signatures``), so it never lands in +# ``used``. Adding it here would be actively wrong - the interface +# loop would then try to emit ``interface unsafe { ... }`` and +# raise ``UnsupportedCapabilityMethod`` on the first method +# instead of skipping the cap. Kept as a literal (rather than +# ``BUILTIN_CAPS - {"Unsafe"}``) so a newly added capability is +# NOT silently assumed to have WIT signatures; the guard in +# ``TestCapabilityRegistry`` (tests/test_cap_handles.py) flags the +# omission when a new cap appears. _KNOWN_CAPABILITIES = {"Stdio", "Clock", "Env", "Fs", "Random", "Net", "Db", "Proc"} @@ -677,7 +691,7 @@ def collect_used_capabilities(module: Module) -> dict[str, set[str]]: return out -from ._capa_types import BUILTIN_CAPS +from ._capa_types import BUILTIN_CAPS, HANDLE_BEARING_CAPS def _module_calls_panic(module: Module) -> bool: @@ -706,7 +720,7 @@ def _module_calls_panic(module: Module) -> bool: # too (metadata + streams + the guest-side restrict_to / allows), but the # WIT generator does not consult this set for Fs: it skips the WHOLE Fs # capa:host interface in WASI mode unconditionally (see the -# ``cap in ("Random", "Clock", "Env", "Fs")`` skip in ``_emit_wit_wasi``), +# ``_WASI_FULLY_MIGRATED_CAPS`` skip in ``_emit_wit_wasi``), # routing every Fs op to wasi:filesystem. This constant is unused by the # generator (the per-cap skips drive the logic); it is kept only as a # human-readable index, NOT a lockstep mirror of @@ -738,6 +752,21 @@ def _module_calls_panic(module: Module) -> bool: ) +# Capabilities that are FULLY migrated to wasi:* under ``--wasi``: +# every reachable method either routes to a wasi interface or is +# implemented guest-side, so the cap carries NO ``capa:host`` +# interface and no ``capa:host`` world import. This is NOT a subset +# of ``HANDLE_BEARING_CAPS`` (Random is erased on the wasm side yet +# fully migrated here, and Db / Proc bear handles yet are NOT +# migrated) - the two express different axes, so this set is +# maintained independently. Stdio is migrated per-METHOD rather +# than wholesale (``_WASI_STDIO_MIGRATED_METHODS`` above), so it +# does not belong here. +_WASI_FULLY_MIGRATED_CAPS: frozenset[str] = frozenset({ + "Random", "Clock", "Env", "Fs", "Net", +}) + + def emit_wit( module: Module, world_name: str = "program", @@ -897,24 +926,21 @@ def _emit_wit_wasi( lines.append("") # ``capa:host`` interfaces for every NON-migrated capability, - # identical to the default path. Random / Clock / Env / Fs are - # skipped (they move to wasi:*). Fs in WASI mode is metadata-only - # (exists / is_dir / mkdir via wasi:filesystem); the stream-bearing - # and attenuation methods are rejected by the Wasm emitter's - # ``_validate_wasi_caps`` before we get here, so an Fs present in - # ``used`` carries no ``capa:host`` fs interface. + # identical to the default path. ``_WASI_FULLY_MIGRATED_CAPS`` + # is skipped (those move to wasi:*). Fs in WASI mode is + # metadata-only (exists / is_dir / mkdir via wasi:filesystem); + # the stream-bearing and attenuation methods are rejected by the + # Wasm emitter's ``_validate_wasi_caps`` before we get here, so + # an Fs present in ``used`` carries no ``capa:host`` fs + # interface. Net is migrated the same way: the request ops + # ``get`` (Phase 1) and ``post`` (Phase 2) route to wasi:http, + # and the fine attenuators ``restrict_to`` / ``allows`` (Phase 3) + # are implemented GUEST-SIDE (Level 2 of + # ``docs/design/wasi-attenuation.md``, no host import). for cap in sorted(used.keys()): if cap not in _KNOWN_CAPABILITIES: continue - if cap in ("Random", "Clock", "Env", "Fs"): - continue - # Net in WASI mode is FULLY migrated: the request ops ``get`` - # (Phase 1) and ``post`` (Phase 2) route to wasi:http, and the fine - # attenuators ``restrict_to`` / ``allows`` (Phase 3) are implemented - # GUEST-SIDE (Level 2 of ``docs/design/wasi-attenuation.md``, no - # host import). So a Net present in ``used`` carries no ``capa:host`` - # net interface (mirroring Random / Clock / Env / Fs). - if cap == "Net": + if cap in _WASI_FULLY_MIGRATED_CAPS: continue # Stdio output migration (Phase 1, 2026-06-29): print / println / # eprintln route to wasi:cli/stdout|stderr (the world imports @@ -1053,9 +1079,8 @@ def _emit_wit_wasi( if used["Stdio"] - _WASI_STDIO_MIGRATED_METHODS: lines.append(" import stdio;") continue - if cap in _KNOWN_CAPABILITIES and cap not in ( - "Random", "Clock", "Env", "Fs", "Net", - ): + if (cap in _KNOWN_CAPABILITIES + and cap not in _WASI_FULLY_MIGRATED_CAPS): lines.append(f" import {cap.lower()};") # De-duplicate ``import`` lines (a wasi:io interface can be requested # by both an Fs stream op and Net.get); a world importing the same @@ -1088,17 +1113,6 @@ def _emit_wit_wasi( return "\n".join(lines) -# Caps that lower to a real i32 handle on ``main``'s wasm signature -# (slices 25.2 - 25.6). Mirrors ``_emit_wasm.__init__._emit_function`` -# and ``_wasm_host.WasmHost.run_main``: Fs / Net / Db / Proc / Env / -# Clock thread through the host handle table; everything else -# (Stdio / Random / Unsafe) stays erased on the wasm side and so on -# the WIT side too. -_HANDLE_BEARING_CAPS: frozenset[str] = frozenset({ - "Fs", "Net", "Db", "Proc", "Env", "Clock", -}) - - def _main_handle_param_names(module: Module) -> list[str]: """Return the lowercase cap-param names of ``main`` for the WIT world export, in declaration order, filtered to caps that lower @@ -1113,7 +1127,7 @@ def _main_handle_param_names(module: Module) -> list[str]: continue out: list[str] = [] for p in fn.params: - if p.ty in _HANDLE_BEARING_CAPS: + if p.ty in HANDLE_BEARING_CAPS: # WIT identifiers are strict kebab-case (lowercase # ASCII letters / digits / dashes). Capa source- # level param names are typically already conformant diff --git a/capa/lsp/completion.py b/capa/lsp/completion.py index bfb9e346..16ea3dd7 100644 --- a/capa/lsp/completion.py +++ b/capa/lsp/completion.py @@ -22,7 +22,7 @@ from ..lexer import Lexer from ..parser import Parser, ParserError from ..tokens import KEYWORDS -from ..typesys import TyFun, TyName, ty_str +from ..typesys import CAPABILITY_NAMES, TyFun, TyName, ty_str from .context import LspContext, walk @@ -34,9 +34,12 @@ "List", "Option", "Result", "Map", "Set", "Fun", "JsonValue", "IoError", ] -_BUILTIN_CAPABILITIES = [ - "Stdio", "Fs", "Net", "Env", "Clock", "Random", "Unsafe", -] +# Capabilities are NOT curated: the built-in set is small and every +# member is completable, so deriving from the checker's registry is +# both complete and drift-proof. It was hand-copied until 2026-07-18 +# and had gone stale - ``Proc`` and ``Db`` shipped without ever being +# added here, so neither was offered by the editor. +_BUILTIN_CAPABILITIES = sorted(CAPABILITY_NAMES) _BUILTIN_VARIANTS = ["Some", "None", "Ok", "Err"] _BUILTIN_FUNCTIONS = [ "parse_int", "parse_float", "to_int", "to_float", @@ -94,8 +97,10 @@ def compute_completions( def _floor_completions() -> list[Completion]: - """Keywords + curated built-ins. Always present so the - suggestion list never goes dark on a half-typed line.""" + """Keywords + built-ins. Always present so the suggestion list + never goes dark on a half-typed line. Types, variants and + functions are curated; capabilities are derived from the + checker's registry so they cannot go stale.""" out: list[Completion] = [] for kw in KEYWORDS: out.append(Completion(label=kw, kind="keyword")) diff --git a/capa/runtime/_wasm_host.py b/capa/runtime/_wasm_host.py index f34926e1..80995d53 100644 --- a/capa/runtime/_wasm_host.py +++ b/capa/runtime/_wasm_host.py @@ -45,6 +45,7 @@ import wasmtime import wasmtime.component as wc +from ..ir._capa_types import HANDLE_BEARING_CAPS from ._capabilities import Clock, Db, Env, Fs, Net, Proc, Stdio, _write_safe from ._fs_guard import PostOpenDenied from ._cap_handles import ( @@ -3165,14 +3166,7 @@ def _invoke_main(self, main, n_params: int, param_names: list) -> None: clock=self._root_clock, stdio=self._root_stdio, ) - name_to_root: dict[str, int] = { - "fs": roots.get("fs", 0), - "net": roots.get("net", 0), - "db": roots.get("db", 0), - "proc": roots.get("proc", 0), - "env": roots.get("env", 0), - "clock": roots.get("clock", 0), - } + name_to_root = _root_handle_map(roots) handle_args: list[int] = [] for i in range(n_params): name = param_names[i] if i < len(param_names) else "" @@ -3183,6 +3177,30 @@ def _invoke_main(self, main, n_params: int, param_names: list) -> None: # ---- helpers ---------------------------------------------------- +def _root_handle_map(roots: dict) -> dict[str, int]: + """Map ``main``'s cap PARAM NAME to the root handle it should + receive, for the caps that lower to an i32 handle. + + DERIVED from ``HANDLE_BEARING_CAPS`` rather than hand-listed, so + reclassifying a capability as handle-bearing cannot leave this + map behind. It was a hardcoded six-entry literal until + 2026-07-18; had a future attenuation slice un-erased Stdio or + Random, that slice would have had to remember to edit this + literal too, and forgetting would have silently routed the new + cap's slot to the Fs root instead of failing. + + Extracted from ``_invoke_main`` (and kept module-level) so the + registry guard in ``tests/test_cap_handles.py`` can assert + against the real map rather than against + ``bootstrap_root_handles``' output, which is a strictly larger + dict (it also serves the erased caps). + """ + return { + cap.lower(): roots.get(cap.lower(), 0) + for cap in HANDLE_BEARING_CAPS + } + + def _read_uleb128(buf: bytes, off: int) -> tuple[int, int]: """Decode an unsigned LEB128 from ``buf`` at ``off``; returns ``(value, next_offset)``. Used by the wasm name-section parser.""" diff --git a/tests/test_cap_handles.py b/tests/test_cap_handles.py index 2b7be84a..e236b834 100644 --- a/tests/test_cap_handles.py +++ b/tests/test_cap_handles.py @@ -10,15 +10,42 @@ CapHandleTable, bootstrap_root_handles, ) +from capa.ir._capa_types import ( + BUILTIN_CAPS, + ERASED_CAPS, + HANDLE_BEARING_CAPS, +) +from capa.ir._emit_wit import _KNOWN_CAPABILITIES +from capa.lsp.completion import _BUILTIN_CAPABILITIES from capa.runtime._capabilities import ( Clock, + Db, Env, Fs, Net, + Proc, Random, Stdio, Unsafe, ) +from capa.typesys import CAPABILITY_NAMES + + +# NOTE: ``capa.runtime._wasm_host`` imports ``wasmtime`` at module +# load, so it is NOT imported at top level here: the plain ``test`` +# CI job does not install the ``wasm`` extra and importing it would +# ERROR the whole module at collection (a failed import is an error, +# not a skip). Only the root-handle guard needs it; it imports the +# symbol LOCALLY and is gated on ``_has_wasmtime()``. Every other +# capability-registry guard is pure data and MUST keep running on +# the no-wasm job -- that is the whole reason these guards live here +# instead of in the skip-happy ``tests/test_properties.py``. +def _has_wasmtime() -> bool: + try: + import wasmtime # noqa: F401 + return True + except ImportError: + return False class TestCapHandleTable(unittest.TestCase): @@ -139,5 +166,129 @@ def test_table_len_reflects_allocations(self): self.assertEqual(len(t), 3) +class TestCapabilityRegistry(unittest.TestCase): + """Cross-checks on the built-in capability registry. + + The set of built-in capabilities is spelled out in more than one + place: the front end knows it as ``typesys.CAPABILITY_NAMES``, + the backends as ``ir._capa_types.BUILTIN_CAPS``, and the Wasm + side additionally splits it into handle-bearing vs erased. All + nine caps date from the initial commit and nobody has ever added + one, so nothing has ever exercised the copies for agreement - + and two of them had silently drifted. These tests are the guard + that makes a tenth capability impossible to half-land. + + ``ir/_capa_types.py`` used to promise this cross-check lived in + ``tests/test_properties.py``; it never did, and that module + skips wholesale when Hypothesis is missing, so the guard lives + here where it always runs. + """ + + def test_frontend_and_backend_capability_sets_agree(self): + # A capability the checker accepts as a type annotation but + # the backends do not know about lowers to a user type and + # miscompiles silently; the reverse makes the backend carry + # dead paths for a name no program can ever write. + missing_in_backend = CAPABILITY_NAMES - BUILTIN_CAPS + missing_in_frontend = BUILTIN_CAPS - CAPABILITY_NAMES + self.assertEqual( + missing_in_backend, set(), + "capa.typesys.CAPABILITY_NAMES has capabilities the " + "backends do not know about (add them to " + "capa.ir._capa_types.BUILTIN_CAPS): " + f"{sorted(missing_in_backend)}", + ) + self.assertEqual( + missing_in_frontend, set(), + "capa.ir._capa_types.BUILTIN_CAPS has capabilities the " + "checker does not accept (add them to " + "capa.typesys.CAPABILITY_NAMES): " + f"{sorted(missing_in_frontend)}", + ) + + def test_every_builtin_cap_is_classified_handle_bearing_or_erased(self): + # Completeness guard: a new capability must be a deliberate + # member of exactly one of the two Wasm-lowering classes. If + # this fires, decide whether the cap carries an i32 handle + # into the host cap table (attenuable, must survive function + # boundaries) or is erased, then record it. + unclassified = BUILTIN_CAPS - HANDLE_BEARING_CAPS - ERASED_CAPS + self.assertEqual( + unclassified, set(), + "capabilities with no Wasm lowering class; add each to " + "HANDLE_BEARING_CAPS or ERASED_CAPS in " + f"capa/ir/_capa_types.py: {sorted(unclassified)}", + ) + + def test_handle_bearing_and_erased_are_disjoint_subsets(self): + self.assertEqual( + HANDLE_BEARING_CAPS & ERASED_CAPS, set(), + "a capability cannot be both handle-bearing and erased", + ) + stray = (HANDLE_BEARING_CAPS | ERASED_CAPS) - BUILTIN_CAPS + self.assertEqual( + stray, set(), + "Wasm lowering classes name capabilities that are not " + f"built-ins: {sorted(stray)}", + ) + + @unittest.skipUnless(_has_wasmtime(), "wasmtime-py not installed") + def test_host_root_handle_map_covers_every_handle_bearing_cap(self): + # Imported here, not at module scope: ``_wasm_host`` pulls in + # wasmtime, and this is the only guard in the class that + # needs it. The rest stay importable on the no-wasm job. + from capa.runtime._wasm_host import _root_handle_map + + # ``WasmHost._invoke_main`` maps each i32 slot of ``main`` to + # a root handle by the PARAM NAME recovered from the wasm + # name section (NOT by cap type - the host never sees the + # type, only the identifier the program chose). A handle- + # bearing cap missing from that map falls back to the Fs + # root: a silent cross-cap substitution rather than an error. + # + # This asserts against ``_root_handle_map`` itself, not + # against ``bootstrap_root_handles``' output. The latter is a + # strictly larger dict (it serves the erased caps too), so + # checking it would pass for a cap that bootstrap supports + # but the param-name map omits - exactly the divergence this + # guard exists to catch. + roots = bootstrap_root_handles( + CapHandleTable(), + fs=Fs(), net=Net(), db=Db(), proc=Proc(), + env=Env(), clock=Clock(), stdio=Stdio(), + ) + name_to_root = _root_handle_map(roots) + for cap in sorted(HANDLE_BEARING_CAPS): + self.assertIn( + cap.lower(), name_to_root, + f"{cap} bears a Wasm handle but WasmHost's param-name " + "map has no entry for it, so a main declaring it would " + "silently receive the Fs root (see _root_handle_map)", + ) + self.assertNotEqual( + name_to_root[cap.lower()], 0, + f"{cap} bears a Wasm handle but _invoke_main never " + "bootstraps a root instance for it, so its slot would " + "carry handle 0 (the 'no cap' sentinel)", + ) + + def test_lsp_offers_every_builtin_capability(self): + # The completion floor listed seven of the nine caps until + # 2026-07-18 (Proc and Db shipped without being added), so + # the editor never suggested them. + self.assertEqual(set(_BUILTIN_CAPABILITIES), set(CAPABILITY_NAMES)) + + def test_wit_generator_knows_every_capability_except_unsafe(self): + # ``Unsafe`` is deliberately absent from the WIT generator's + # known set: it is the Python-only FFI escape hatch and the + # Wasm emitter rejects it up front, so it can never reach WIT + # generation. Every OTHER built-in must be known, or its + # interface would be silently skipped and the host would be + # asked for an import it was never told to provide. + self.assertEqual( + set(_KNOWN_CAPABILITIES), set(BUILTIN_CAPS) - {"Unsafe"}, + ) + + if __name__ == "__main__": unittest.main()