From 7e6c23d0d32d52e76b15d760033870bbdc26c95d Mon Sep 17 00:00:00 2001 From: Daniel Vigovszky Date: Fri, 15 May 2026 17:29:20 +0200 Subject: [PATCH 1/3] Capability detection, WIP Co-authored-by: Daniel Vigovszky --- Cargo.lock | 17 + Cargo.toml | 1 + capability-detection.md | 133 ++ crates/wasm-rquickjs/Cargo.toml | 5 + .../wasm-rquickjs/skeleton/src/builtin/mod.rs | 1471 +++++++++++++---- .../skeleton/src/capabilities.rs | 258 +++ crates/wasm-rquickjs/skeleton/src/lib.rs | 1 + crates/wasm-rquickjs/src/capability_scan.rs | 1370 +++++++++++++++ crates/wasm-rquickjs/src/exports.rs | 5 +- crates/wasm-rquickjs/src/inject.rs | 248 +++ crates/wasm-rquickjs/src/lib.rs | 7 +- src/cli.rs | 55 + src/main.rs | 238 ++- tests/binary_inject.rs | 122 ++ 14 files changed, 3573 insertions(+), 358 deletions(-) create mode 100644 capability-detection.md create mode 100644 crates/wasm-rquickjs/skeleton/src/capabilities.rs create mode 100644 crates/wasm-rquickjs/src/capability_scan.rs diff --git a/Cargo.lock b/Cargo.lock index a93d55b21..68a32c46e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1928,6 +1928,18 @@ dependencies = [ "syn", ] +[[package]] +name = "oxc_ast_visit" +version = "0.115.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e33ffb874949ea07fce9b686c2dba7e221c849e047232c04a84b13bae4496ef" +dependencies = [ + "oxc_allocator", + "oxc_ast", + "oxc_span", + "oxc_syntax", +] + [[package]] name = "oxc_data_structures" version = "0.115.0" @@ -3467,6 +3479,11 @@ dependencies = [ "heck", "include_dir", "indexmap", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_parser", + "oxc_span", "prettier-please", "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index e6ff4f9c8..45fd9b975 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -183,6 +183,7 @@ wit-parser = "0.247.0" oxc_parser = "0.115" oxc_allocator = "0.115" oxc_ast = "0.115" +oxc_ast_visit = "0.115" oxc_span = "0.115" # Golem's forked wasmtime — uncomment these patches together with enabling the diff --git a/capability-detection.md b/capability-detection.md new file mode 100644 index 000000000..185a3d0a9 --- /dev/null +++ b/capability-detection.md @@ -0,0 +1,133 @@ +# Plan: Per-app builtin trimming for wasm-rquickjs + +This plan tracks the remaining work for the runtime capability-gates approach. +The wasm-rquickjs side prototype is in place: gates slot in the skeleton, +host-side patching of all gate slot copies, CLI flags on `inject-js`, and an +end-to-end integration test. + +## 0. Wait for the wasm-level dead-code / import eliminator + +**Blocker for actually shipping size + import reductions to users.** + +- The current scheme only flips a runtime bit. Without DCE on the produced wasm, + disabling a capability does not remove its native code, JS source bytes, or + the WIT imports it transitively pulls in. +- A separate tool (already in development by the user) is expected to: + - perform component-level dead-code elimination, and + - drop unused component model imports based on what is reachable. +- We need to wait until that tool is usable end-to-end on a patched + wasm-rquickjs base image before we start measuring real wins. +- Action: once the tool exists, run it on a `inject-js --auto-trim`-patched + artifact and confirm: + - native builtin Rust code for disabled caps is gone, + - JS bodies for disabled caps are gone, + - WIT imports that are now dead (e.g. `wasi:filesystem` when `Fs` is off) are + actually dropped from the final component. + +Until that confirmation lands, everything below is preparation, polish, and +correctness work, not user-visible size wins. + +## 1. Run the broader binary_inject suite + +- Only the new `test_patch_capability_gates_and_run` was run in isolation. +- Run the full suite to catch regressions: + ``` + cargo test --release --test binary_inject -- --nocapture + ``` +- If any pre-existing test fails because of the gates work, fix or annotate + before merging. + +## 2. CLI / docs polish for `inject-js` + +Current behavior of the new flags is intentional but subtle: + +- With **none** of `--auto-trim`, `--include`, `--exclude`: behaves like the + old `inject-js` (no patching). +- With `--auto-trim`: scans the JS entry files and computes the closure. +- With only `--include` / `--exclude` (no `--auto-trim`): starts from + "all enabled" and disables only what was excluded; `--include` is additive + on top of that. + +Action items: + +- Make `--help` text spell this out clearly so users understand: + - the default baseline used when `--auto-trim` is absent, + - that `--exclude` removes from full, + - that `--include` survives `--auto-trim`'s closure. +- When patching, log not just the count but the **enabled capability names** + (or at least the disabled ones) so users can sanity-check what they shipped. +- Mention the gates story briefly in `README.md` once the DCE tool lands. + +## 3. Add a `inspect-capabilities` (or similar) subcommand + +Not strictly required, but very useful for debugging/auditing: + +- Read the gates slot(s) from a wasm/component file. +- Verify all slot copies agree (reuse `read_capability_gates_from_bytes`). +- Print: + - the raw `u64`, + - the list of enabled capability names, + - the list of disabled capability names. +- Should also handle the "no gates marker" case with a friendly message that + this wasm predates capability-gates support. + +## 4. Static-import dependency graph for builtin JS (correctness follow-up) + +Discovered runtime caveat: some builtin JS bodies statically `import` other +builtins. The most obvious offender is `node:module` (`module.js`) which pulls +in many builtins, including `node:vm`. If the closure is computed only from the +manually curated `dependencies()` table in +[capability_scan.rs](file:///Users/vigoo/projects/golem/wasm-rquickjs/crates/wasm-rquickjs/src/capability_scan.rs), +trimming the wrong cap leaves a dangling static `import` and the runtime fails +during built-in wiring with messages like +`No such built-in module: node:vm`. + +Options to consider: + +- Derive an extra graph by scanning `crates/wasm-rquickjs/skeleton/src/builtin/**/*.js` + for static `import` specifiers and unioning that into the dependency closure + used by `Policy`. +- Or treat known umbrella caps (start with `Module`) as depending on every cap + their JS imports, hard-coded. +- Or refuse to trim umbrella caps unless every transitively imported cap is + also still enabled. + +The mechanism itself is correct; this is about preventing unsafe trim policies. +Pick whichever is simplest to maintain. + +## 5. Optional: surface the closure expansion in `--auto-trim` + +When `--auto-trim` adds caps purely because of the dependency closure (or, once +implemented, the static-import graph from step 4), it would help debugging to +print the difference between: + +- caps directly used by the JS, and +- caps added solely because of dependency closure. + +This makes it obvious why something the user "doesn't use" is still enabled. + +## 6. Future: precision improvements to the JS scanner + +Out of scope for the current prototype, but worth noting: + +- Better detection of dynamic `require` / `import()` patterns. +- Optional config to override the scanner's verdict per-app. +- Possibly a "strict" mode that errors instead of silently keeping all + capabilities when the scanner is uncertain. + +These can be revisited once we have real users running with `--auto-trim`. + +## 7. End-to-end size measurement once the DCE tool exists + +Tied to step 0. Once the DCE/import-stripper tool is available: + +- Build a baseline wasm-rquickjs base image (no patching, no DCE). +- Build the same base image with `inject-js --auto-trim` for the Golem TS + template scenario described in the experiment. +- Run the DCE/import-stripper on that. +- Compare: + - final component size, + - WIT imports present in the final component, + - cold-start time if measurable. + +Document the numbers in the README (or a dedicated benchmarks doc). diff --git a/crates/wasm-rquickjs/Cargo.toml b/crates/wasm-rquickjs/Cargo.toml index b51ff0920..beb9df9d7 100644 --- a/crates/wasm-rquickjs/Cargo.toml +++ b/crates/wasm-rquickjs/Cargo.toml @@ -21,6 +21,11 @@ camino = { workspace = true } heck = { workspace = true } include_dir = { workspace = true } indexmap = "2.11.0" +oxc_allocator = { workspace = true } +oxc_ast = { workspace = true } +oxc_ast_visit = { workspace = true } +oxc_parser = { workspace = true } +oxc_span = { workspace = true } prettier-please = { workspace = true } proc-macro2 = { workspace = true } quote = { workspace = true } diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs b/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs index 7a2e860d1..72c69bd2e 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs @@ -1,5 +1,11 @@ +use crate::capabilities::{self, Capability}; use std::fmt::Write; +#[inline] +fn cap(c: Capability) -> bool { + capabilities::is_enabled(c) +} + mod abort_controller; mod abort_signal; mod assert; @@ -126,171 +132,461 @@ pub(crate) fn realpath_for_module_resolution_with_symlinks( pub fn add_module_resolvers( resolver: rquickjs::loader::BuiltinResolver, ) -> rquickjs::loader::BuiltinResolver { - let resolver = resolver - .with_module("__wasm_rquickjs_builtin/abort_controller") - .with_module("__wasm_rquickjs_builtin/base64_native") - .with_module("__wasm_rquickjs_builtin/console_native") - .with_module("__wasm_rquickjs_builtin/console") - .with_module("__wasm_rquickjs_builtin/timeout_native") - .with_module("__wasm_rquickjs_builtin/timeout") - .with_module("__wasm_rquickjs_builtin/gc_native") - .with_module("__wasm_rquickjs_builtin/http_native") - .with_module("__wasm_rquickjs_builtin/http") - .with_module("__wasm_rquickjs_builtin/http_blob") - .with_module("__wasm_rquickjs_builtin/http_form_data") - .with_module("__wasm_rquickjs_builtin/streams") - .with_module("__wasm_rquickjs_builtin/webstreams_wrapper") - .with_module("__wasm_rquickjs_builtin/encoding_native") - .with_module("__wasm_rquickjs_builtin/encoding") - .with_module("__wasm_rquickjs_builtin/intl_native") - .with_module("__wasm_rquickjs_builtin/intl") - .with_module("node:util") - .with_module("node:util/types") - .with_module("util") - .with_module("util/types") - .with_module("__wasm_rquickjs_builtin/fs_native") - .with_module("node:fs") - .with_module("fs") - .with_module("node:fs/promises") - .with_module("fs/promises") - .with_module("internal/fs/promises") - .with_module("node:buffer") - .with_module("buffer") - .with_module("base64-js") - .with_module("ieee754") - .with_module("__wasm_rquickjs_builtin/os_native") - .with_module("node:os") - .with_module("os") - .with_module("node:assert") - .with_module("assert") - .with_module("node:assert/strict") - .with_module("assert/strict") - .with_module("node:querystring") - .with_module("querystring") - .with_module("node:child_process") - .with_module("child_process") - .with_module("node:test") - .with_module("node:module") - .with_module("module") - .with_module("__wasm_rquickjs_builtin/process_native") - .with_module("node:process") - .with_module("process") - .with_module("node:path") - .with_module("path") - .with_module("node:path/posix") - .with_module("path/posix") - .with_module("node:path/win32") - .with_module("path/win32") - .with_module("node:punycode") - .with_module("punycode") - .with_module("__wasm_rquickjs_builtin/url_native") - .with_module("__wasm_rquickjs_builtin/url") - .with_module("node:url") - .with_module("url") - .with_module("node:events") - .with_module("events") - .with_module("node:stream") - .with_module("node:stream/promises") - .with_module("node:stream/consumers") - .with_module("node:stream/web") - .with_module("stream") - .with_module("stream/promises") - .with_module("stream/consumers") - .with_module("stream/web") - .with_module("web-streams-polyfill") - .with_module("formdata-node") - .with_module("__wasm_rquickjs_builtin/string_decoder_native") - .with_module("node:string_decoder") - .with_module("string_decoder") - .with_module("node:timers") - .with_module("timers") - .with_module("node:timers/promises") - .with_module("timers/promises") - .with_module("__wasm_rquickjs_builtin/web_crypto_native") - .with_module("__wasm_rquickjs_builtin/web_crypto") - .with_module("node:crypto") - .with_module("crypto") - .with_module("__wasm_rquickjs_builtin/vm_native") - .with_module("__wasm_rquickjs_builtin/vm") - .with_module("node:vm") - .with_module("vm") - .with_module("__wasm_rquickjs_builtin/structured_clone") - .with_module("node:async_hooks") - .with_module("async_hooks") - .with_module("node:cluster") - .with_module("cluster") - .with_module("node:constants") - .with_module("constants") - .with_module("__wasm_rquickjs_builtin/dgram_native") - .with_module("node:dgram") - .with_module("dgram") - .with_module("node:diagnostics_channel") - .with_module("diagnostics_channel") - .with_module("__wasm_rquickjs_builtin/dns_native") - .with_module("node:dns") - .with_module("dns") - .with_module("node:dns/promises") - .with_module("dns/promises") - .with_module("node:domain") - .with_module("domain") - .with_module("node:http2") - .with_module("http2") - .with_module("node:https") - .with_module("https") - .with_module("node:inspector") - .with_module("inspector") - .with_module("__wasm_rquickjs_builtin/node_http_native") - .with_module("__wasm_rquickjs_builtin/node_http_server") - .with_module("node:_http_common") - .with_module("_http_common") - .with_module("node:_http_agent") - .with_module("_http_agent") - .with_module("node:http") - .with_module("http") - .with_module("__wasm_rquickjs_builtin/net_native") - .with_module("node:net") - .with_module("net") - .with_module("node:perf_hooks") - .with_module("perf_hooks") - .with_module("node:readline") - .with_module("readline") - .with_module("node:readline/promises") - .with_module("readline/promises") - .with_module("node:repl") - .with_module("repl") - .with_module("node:console") - .with_module("console") - .with_module("node:trace_events") - .with_module("trace_events") - .with_module("node:tls") - .with_module("tls") - .with_module("node:tty") - .with_module("tty") - .with_module("node:v8") - .with_module("v8") - .with_module("node:worker_threads") - .with_module("worker_threads") - .with_module("__wasm_rquickjs_builtin/zlib_native") - .with_module("node:zlib") - .with_module("zlib") - // SQLite - only node:sqlite, no bare "sqlite" (matches Node.js behavior) - .with_module("__wasm_rquickjs_builtin/sqlite_native") - .with_module("node:sqlite"); + // Each block below is gated by a single capability. The default gate value + // is "enabled", so this is a no-op shape change unless the host patches the + // capability slot in the wasm. See `crate::capabilities`. + + let resolver = if cap(Capability::AbortController) { + resolver.with_module("__wasm_rquickjs_builtin/abort_controller") + } else { + resolver + }; + + let resolver = if cap(Capability::Base64) { + resolver.with_module("__wasm_rquickjs_builtin/base64_native") + } else { + resolver + }; + + let resolver = if cap(Capability::Console) { + resolver + .with_module("__wasm_rquickjs_builtin/console_native") + .with_module("__wasm_rquickjs_builtin/console") + .with_module("node:console") + .with_module("console") + } else { + resolver + }; + + let resolver = if cap(Capability::Timers) { + resolver + .with_module("__wasm_rquickjs_builtin/timeout_native") + .with_module("__wasm_rquickjs_builtin/timeout") + .with_module("node:timers") + .with_module("timers") + .with_module("node:timers/promises") + .with_module("timers/promises") + } else { + resolver + }; + + let resolver = if cap(Capability::Gc) { + resolver.with_module("__wasm_rquickjs_builtin/gc_native") + } else { + resolver + }; + + let resolver = if cap(Capability::NodeFetch) { + resolver + .with_module("__wasm_rquickjs_builtin/http_native") + .with_module("__wasm_rquickjs_builtin/http") + .with_module("__wasm_rquickjs_builtin/http_blob") + .with_module("__wasm_rquickjs_builtin/http_form_data") + } else { + resolver + }; + + let resolver = if cap(Capability::Webstreams) { + resolver + .with_module("__wasm_rquickjs_builtin/streams") + .with_module("__wasm_rquickjs_builtin/webstreams_wrapper") + .with_module("node:stream/web") + .with_module("stream/web") + .with_module("web-streams-polyfill") + } else { + resolver + }; + + let resolver = if cap(Capability::Encoding) { + resolver + .with_module("__wasm_rquickjs_builtin/encoding_native") + .with_module("__wasm_rquickjs_builtin/encoding") + } else { + resolver + }; + + let resolver = if cap(Capability::Intl) { + resolver + .with_module("__wasm_rquickjs_builtin/intl_native") + .with_module("__wasm_rquickjs_builtin/intl") + } else { + resolver + }; + + let resolver = if cap(Capability::Util) { + resolver + .with_module("node:util") + .with_module("node:util/types") + .with_module("util") + .with_module("util/types") + } else { + resolver + }; + + let resolver = if cap(Capability::Fs) { + resolver + .with_module("__wasm_rquickjs_builtin/fs_native") + .with_module("node:fs") + .with_module("fs") + .with_module("node:fs/promises") + .with_module("fs/promises") + .with_module("internal/fs/promises") + } else { + resolver + }; + + let resolver = if cap(Capability::Buffer) { + resolver + .with_module("node:buffer") + .with_module("buffer") + .with_module("base64-js") + .with_module("ieee754") + } else { + resolver + }; + + let resolver = if cap(Capability::Os) { + resolver + .with_module("__wasm_rquickjs_builtin/os_native") + .with_module("node:os") + .with_module("os") + } else { + resolver + }; + + let resolver = if cap(Capability::Assert) { + resolver + .with_module("node:assert") + .with_module("assert") + .with_module("node:assert/strict") + .with_module("assert/strict") + } else { + resolver + }; + + let resolver = if cap(Capability::Querystring) { + resolver + .with_module("node:querystring") + .with_module("querystring") + } else { + resolver + }; + + let resolver = if cap(Capability::ChildProcess) { + resolver + .with_module("node:child_process") + .with_module("child_process") + } else { + resolver + }; + + let resolver = if cap(Capability::NodeTest) { + resolver.with_module("node:test") + } else { + resolver + }; + + let resolver = if cap(Capability::Module) { + resolver.with_module("node:module").with_module("module") + } else { + resolver + }; + + let resolver = if cap(Capability::Process) { + resolver + .with_module("__wasm_rquickjs_builtin/process_native") + .with_module("node:process") + .with_module("process") + } else { + resolver + }; + + let resolver = if cap(Capability::Path) { + resolver + .with_module("node:path") + .with_module("path") + .with_module("node:path/posix") + .with_module("path/posix") + .with_module("node:path/win32") + .with_module("path/win32") + } else { + resolver + }; + + let resolver = if cap(Capability::Punycode) { + resolver + .with_module("node:punycode") + .with_module("punycode") + } else { + resolver + }; + + let resolver = if cap(Capability::Url) { + resolver + .with_module("__wasm_rquickjs_builtin/url_native") + .with_module("__wasm_rquickjs_builtin/url") + .with_module("node:url") + .with_module("url") + } else { + resolver + }; + + let resolver = if cap(Capability::Events) { + resolver.with_module("node:events").with_module("events") + } else { + resolver + }; + + let resolver = if cap(Capability::Stream) { + resolver + .with_module("node:stream") + .with_module("node:stream/promises") + .with_module("node:stream/consumers") + .with_module("stream") + .with_module("stream/promises") + .with_module("stream/consumers") + } else { + resolver + }; + + let resolver = if cap(Capability::FormDataNode) { + resolver.with_module("formdata-node") + } else { + resolver + }; + + let resolver = if cap(Capability::StringDecoder) { + resolver + .with_module("__wasm_rquickjs_builtin/string_decoder_native") + .with_module("node:string_decoder") + .with_module("string_decoder") + } else { + resolver + }; + + let resolver = if cap(Capability::WebCrypto) { + resolver + .with_module("__wasm_rquickjs_builtin/web_crypto_native") + .with_module("__wasm_rquickjs_builtin/web_crypto") + .with_module("node:crypto") + .with_module("crypto") + } else { + resolver + }; + + let resolver = if cap(Capability::Vm) { + resolver + .with_module("__wasm_rquickjs_builtin/vm_native") + .with_module("__wasm_rquickjs_builtin/vm") + .with_module("node:vm") + .with_module("vm") + } else { + resolver + }; + + let resolver = if cap(Capability::StructuredClone) { + resolver.with_module("__wasm_rquickjs_builtin/structured_clone") + } else { + resolver + }; + + let resolver = if cap(Capability::AsyncHooks) { + resolver + .with_module("node:async_hooks") + .with_module("async_hooks") + } else { + resolver + }; + + let resolver = if cap(Capability::Cluster) { + resolver.with_module("node:cluster").with_module("cluster") + } else { + resolver + }; + + let resolver = if cap(Capability::Constants) { + resolver + .with_module("node:constants") + .with_module("constants") + } else { + resolver + }; + + let resolver = if cap(Capability::Dgram) { + resolver + .with_module("__wasm_rquickjs_builtin/dgram_native") + .with_module("node:dgram") + .with_module("dgram") + } else { + resolver + }; + + let resolver = if cap(Capability::DiagnosticsChannel) { + resolver + .with_module("node:diagnostics_channel") + .with_module("diagnostics_channel") + } else { + resolver + }; + + let resolver = if cap(Capability::Dns) { + resolver + .with_module("__wasm_rquickjs_builtin/dns_native") + .with_module("node:dns") + .with_module("dns") + .with_module("node:dns/promises") + .with_module("dns/promises") + } else { + resolver + }; + + let resolver = if cap(Capability::Domain) { + resolver.with_module("node:domain").with_module("domain") + } else { + resolver + }; + + let resolver = if cap(Capability::Http2) { + resolver.with_module("node:http2").with_module("http2") + } else { + resolver + }; + + let resolver = if cap(Capability::Https) { + resolver.with_module("node:https").with_module("https") + } else { + resolver + }; + + let resolver = if cap(Capability::Inspector) { + resolver + .with_module("node:inspector") + .with_module("inspector") + } else { + resolver + }; + + let resolver = if cap(Capability::NodeHttp) { + resolver + .with_module("__wasm_rquickjs_builtin/node_http_native") + .with_module("__wasm_rquickjs_builtin/node_http_server") + .with_module("node:_http_common") + .with_module("_http_common") + .with_module("node:_http_agent") + .with_module("_http_agent") + .with_module("node:http") + .with_module("http") + } else { + resolver + }; + + let resolver = if cap(Capability::Net) { + resolver + .with_module("__wasm_rquickjs_builtin/net_native") + .with_module("node:net") + .with_module("net") + } else { + resolver + }; + + let resolver = if cap(Capability::PerfHooks) { + resolver + .with_module("node:perf_hooks") + .with_module("perf_hooks") + } else { + resolver + }; + + let resolver = if cap(Capability::Readline) { + resolver + .with_module("node:readline") + .with_module("readline") + .with_module("node:readline/promises") + .with_module("readline/promises") + } else { + resolver + }; + + let resolver = if cap(Capability::Repl) { + resolver.with_module("node:repl").with_module("repl") + } else { + resolver + }; + + let resolver = if cap(Capability::TraceEvents) { + resolver + .with_module("node:trace_events") + .with_module("trace_events") + } else { + resolver + }; + + let resolver = if cap(Capability::Tls) { + resolver.with_module("node:tls").with_module("tls") + } else { + resolver + }; + + let resolver = if cap(Capability::Tty) { + resolver.with_module("node:tty").with_module("tty") + } else { + resolver + }; + + let resolver = if cap(Capability::V8) { + resolver.with_module("node:v8").with_module("v8") + } else { + resolver + }; + + let resolver = if cap(Capability::WorkerThreads) { + resolver + .with_module("node:worker_threads") + .with_module("worker_threads") + } else { + resolver + }; + + let resolver = if cap(Capability::Zlib) { + resolver + .with_module("__wasm_rquickjs_builtin/zlib_native") + .with_module("node:zlib") + .with_module("zlib") + } else { + resolver + }; + + // SQLite - only node:sqlite, no bare "sqlite" (matches Node.js behavior) + let resolver = if cap(Capability::Sqlite) { + resolver + .with_module("__wasm_rquickjs_builtin/sqlite_native") + .with_module("node:sqlite") + } else { + resolver + }; + + #[cfg(feature = "golem")] + let resolver = if cap(Capability::DiagnosticsChannel) { + resolver + .with_module("__wasm_rquickjs_builtin/diagnostics_channel_native") + .with_module("__wasm_rquickjs_builtin/diagnostics_channel_golem") + } else { + resolver + }; let resolver = resolver .with_module("__wasm_rquickjs_builtin/execution_native") .with_module("wasm-rquickjs:execution") .with_module("__wasm_rquickjs_builtin/typescript_native"); - #[cfg(feature = "golem")] - let resolver = resolver - .with_module("__wasm_rquickjs_builtin/diagnostics_channel_native") - .with_module("__wasm_rquickjs_builtin/diagnostics_channel_golem"); - #[cfg(feature = "websocket")] - let resolver = resolver - .with_module("__wasm_rquickjs_builtin/websocket_native") - .with_module("__wasm_rquickjs_builtin/websocket"); + let resolver = if cap(Capability::Websocket) { + resolver + .with_module("__wasm_rquickjs_builtin/websocket_native") + .with_module("__wasm_rquickjs_builtin/websocket") + } else { + resolver + }; internal::add_to_resolver(resolver) } @@ -300,70 +596,183 @@ pub fn module_loader() -> ( rquickjs::loader::BuiltinLoader, rquickjs::loader::BuiltinLoader, ) { - let native_loader = rquickjs::loader::ModuleLoader::default() - .with_module( + // Native module registrations: gated by capability so that the underlying + // `js_native_module` reference becomes unreferenced when a capability is + // disabled, allowing wasm-level dead-code elimination to drop both the + // native function and its component-model imports. + + let native_loader = rquickjs::loader::ModuleLoader::default(); + + let native_loader = if cap(Capability::Base64) { + native_loader.with_module( "__wasm_rquickjs_builtin/base64_native", base64::js_native_module, ) - .with_module( + } else { + native_loader + }; + + let native_loader = if cap(Capability::Console) { + native_loader.with_module( "__wasm_rquickjs_builtin/console_native", console::js_native_module, ) - .with_module( + } else { + native_loader + }; + + let native_loader = if cap(Capability::Timers) { + native_loader.with_module( "__wasm_rquickjs_builtin/timeout_native", timeout::js_native_module, ) - .with_module("__wasm_rquickjs_builtin/gc_native", gc::js_native_module) - .with_module( + } else { + native_loader + }; + + let native_loader = if cap(Capability::Gc) { + native_loader.with_module("__wasm_rquickjs_builtin/gc_native", gc::js_native_module) + } else { + native_loader + }; + + let native_loader = if cap(Capability::NodeFetch) { + native_loader.with_module( "__wasm_rquickjs_builtin/http_native", http::js_native_module, ) - .with_module( + } else { + native_loader + }; + + let native_loader = if cap(Capability::Encoding) { + native_loader.with_module( "__wasm_rquickjs_builtin/encoding_native", encoding::js_native_module, ) - .with_module( + } else { + native_loader + }; + + let native_loader = if cap(Capability::Intl) { + native_loader.with_module( "__wasm_rquickjs_builtin/intl_native", intl::js_native_module, ) - .with_module("__wasm_rquickjs_builtin/fs_native", fs::js_native_module) - .with_module("__wasm_rquickjs_builtin/os_native", os::js_native_module) - .with_module( + } else { + native_loader + }; + + let native_loader = if cap(Capability::Fs) { + native_loader.with_module("__wasm_rquickjs_builtin/fs_native", fs::js_native_module) + } else { + native_loader + }; + + let native_loader = if cap(Capability::Os) { + native_loader.with_module("__wasm_rquickjs_builtin/os_native", os::js_native_module) + } else { + native_loader + }; + + let native_loader = if cap(Capability::Process) { + native_loader.with_module( "__wasm_rquickjs_builtin/process_native", process::js_native_module, ) - .with_module( + } else { + native_loader + }; + + // `internal/binding/util_native` is required by `util` and is treated as + // part of the Util capability — `util.js` re-imports it for low-level + // helpers like inspect. + let native_loader = if cap(Capability::Util) { + native_loader.with_module( "__wasm_rquickjs_builtin/internal/binding/util_native", internal_binding_util::js_native_module, ) - .with_module("__wasm_rquickjs_builtin/url_native", url::js_native_module) - .with_module( + } else { + native_loader + }; + + let native_loader = if cap(Capability::Url) { + native_loader.with_module("__wasm_rquickjs_builtin/url_native", url::js_native_module) + } else { + native_loader + }; + + let native_loader = if cap(Capability::WebCrypto) { + native_loader.with_module( "__wasm_rquickjs_builtin/web_crypto_native", web_crypto::js_native_module, ) - .with_module("__wasm_rquickjs_builtin/vm_native", vm::js_native_module) - .with_module( + } else { + native_loader + }; + + let native_loader = if cap(Capability::Vm) { + native_loader.with_module("__wasm_rquickjs_builtin/vm_native", vm::js_native_module) + } else { + native_loader + }; + + let native_loader = if cap(Capability::Zlib) { + native_loader.with_module( "__wasm_rquickjs_builtin/zlib_native", zlib::js_native_module, ) - .with_module( + } else { + native_loader + }; + + let native_loader = if cap(Capability::Dgram) { + native_loader.with_module( "__wasm_rquickjs_builtin/dgram_native", dgram::js_native_module, ) - .with_module("__wasm_rquickjs_builtin/dns_native", dns::js_native_module) - .with_module( + } else { + native_loader + }; + + let native_loader = if cap(Capability::Dns) { + native_loader.with_module("__wasm_rquickjs_builtin/dns_native", dns::js_native_module) + } else { + native_loader + }; + + let native_loader = if cap(Capability::NodeHttp) { + native_loader.with_module( "__wasm_rquickjs_builtin/node_http_native", node_http::js_native_module, ) - .with_module("__wasm_rquickjs_builtin/net_native", net::js_native_module) - .with_module( + } else { + native_loader + }; + + let native_loader = if cap(Capability::Net) { + native_loader.with_module("__wasm_rquickjs_builtin/net_native", net::js_native_module) + } else { + native_loader + }; + + let native_loader = if cap(Capability::Sqlite) { + native_loader.with_module( "__wasm_rquickjs_builtin/sqlite_native", sqlite::js_native_module, ) - .with_module( + } else { + native_loader + }; + + let native_loader = if cap(Capability::StringDecoder) { + native_loader.with_module( "__wasm_rquickjs_builtin/string_decoder_native", string_decoder::js_native_module, - ); + ) + } else { + native_loader + }; let native_loader = native_loader.with_module( "__wasm_rquickjs_builtin/execution_native", @@ -375,200 +784,548 @@ pub fn module_loader() -> ( ); #[cfg(feature = "golem")] - let native_loader = native_loader.with_module( - "__wasm_rquickjs_builtin/diagnostics_channel_native", - diagnostics_channel::js_native_module, - ); + let native_loader = if cap(Capability::DiagnosticsChannel) { + native_loader.with_module( + "__wasm_rquickjs_builtin/diagnostics_channel_native", + diagnostics_channel::js_native_module, + ) + } else { + native_loader + }; #[cfg(feature = "websocket")] - let native_loader = native_loader.with_module( - "__wasm_rquickjs_builtin/websocket_native", - websocket::js_native_module, - ); + let native_loader = if cap(Capability::Websocket) { + native_loader.with_module( + "__wasm_rquickjs_builtin/websocket_native", + websocket::js_native_module, + ) + } else { + native_loader + }; - let builtin_loader = rquickjs::loader::BuiltinLoader::default() - .with_module( + // Builtin loader: registers JS source strings for each capability's + // user-visible / internal modules. Mirrors the resolver gating above so + // that a disabled capability has neither a name in the resolver nor a + // body in the loader. + + let builtin_loader = rquickjs::loader::BuiltinLoader::default(); + + let builtin_loader = if cap(Capability::AbortController) { + builtin_loader.with_module( "__wasm_rquickjs_builtin/abort_controller", abort_controller::ABORT_CONTROLLER_JS, ) - .with_module("__wasm_rquickjs_builtin/console", console::CONSOLE_JS) - .with_module("__wasm_rquickjs_builtin/timeout", timeout::TIMEOUT_JS) - .with_module("__wasm_rquickjs_builtin/http_blob", http::FETCH_BLOB_JS) - .with_module("__wasm_rquickjs_builtin/http_form_data", http::FORMDATA_JS) - .with_module("__wasm_rquickjs_builtin/http", http::HTTP_JS) - .with_module("__wasm_rquickjs_builtin/streams", webstreams::WEBSTREAMS_JS) - .with_module( - "__wasm_rquickjs_builtin/webstreams_wrapper", - webstreams::WEBSTREAMS_WRAPPER_JS, - ) - .with_module("node:stream/web", webstreams::REEXPORT_JS) - .with_module("stream/web", webstreams::REEXPORT_JS) - .with_module("web-streams-polyfill", webstreams::REEXPORT_JS) - .with_module("formdata-node", formdata_node::FORMDATA_NODE_JS) - .with_module("__wasm_rquickjs_builtin/encoding", encoding::ENCODING_JS) - .with_module("__wasm_rquickjs_builtin/intl", intl::INTL_JS) - .with_module("node:util", util::UTIL_JS) - .with_module("node:util/types", util::UTIL_TYPES_JS) - .with_module("util", util::BARE_UTIL_REEXPORT_JS) - .with_module("util/types", util::UTIL_TYPES_JS) - .with_module("base64-js", base64::BASE64_JS) - .with_module("ieee754", ieee754::IEEE754_JS) - .with_module("node:buffer", buffer::BUFFER_JS) - .with_module("buffer", buffer::REEXPORT_JS) - .with_module("node:fs", fs::FS_JS) - .with_module("fs", fs::REEXPORT_JS) - .with_module("node:fs/promises", fs::FS_PROMISES_JS) - .with_module("fs/promises", fs::REEXPORT_PROMISES_JS) - .with_module("internal/fs/promises", fs::REEXPORT_PROMISES_JS) - .with_module("node:os", os::OS_JS) - .with_module("os", os::REEXPORT_JS) - .with_module("node:assert", assert::ASSERT_JS) - .with_module("assert", assert::REEXPORT_JS) - .with_module("node:assert/strict", assert::ASSERT_STRICT_JS) - .with_module("assert/strict", assert::REEXPORT_STRICT_JS) - .with_module("node:querystring", querystring::QUERYSTRING_JS) - .with_module("querystring", querystring::REEXPORT_JS) - .with_module("node:child_process", child_process::CHILD_PROCESS_JS) - .with_module("child_process", child_process::REEXPORT_JS) - .with_module("node:test", node_test::TEST_JS) - .with_module("node:module", module::MODULE_JS) - .with_module("module", module::REEXPORT_JS) - .with_module("node:process", process::PROCESS_JS) - .with_module("process", process::REEXPORT_JS) - .with_module("node:path", path::PATH_JS) - .with_module("path", path::REEXPORT_JS) - .with_module("node:path/posix", path::PATH_POSIX_REEXPORT_JS) - .with_module("path/posix", path::PATH_POSIX_REEXPORT_JS) - .with_module("node:path/win32", path::PATH_WIN32_REEXPORT_JS) - .with_module("path/win32", path::PATH_WIN32_REEXPORT_JS) - .with_module("node:punycode", punycode::PUNYCODE_JS) - .with_module("punycode", punycode::REEXPORT_JS) - .with_module("__wasm_rquickjs_builtin/url", url::URL_JS) - .with_module("node:url", url::URL_JS) - .with_module("url", url::REEXPORT_JS) - .with_module("node:events", events::EVENTS_JS) - .with_module("events", events::REEXPORT_JS) - .with_module("node:stream", stream::STREAM_JS) - .with_module("stream", stream::REEXPORT_JS) - .with_module("node:stream/promises", stream::STREAM_PROMISES_JS) - .with_module("stream/promises", stream::REEXPORT_PROMISES_JS) - .with_module("node:stream/consumers", stream::STREAM_CONSUMERS_JS) - .with_module("stream/consumers", stream::REEXPORT_CONSUMERS_JS) - .with_module("node:string_decoder", string_decoder::STRING_DECODER_JS) - .with_module("string_decoder", string_decoder::REEXPORT_JS) - .with_module("node:timers", timers::TIMERS_JS) - .with_module("timers", timers::REEXPORT_JS) - .with_module("node:timers/promises", timers::TIMERS_PROMISES_JS) - .with_module("timers/promises", timers::REEXPORT_PROMISES_JS) - .with_module( - "__wasm_rquickjs_builtin/web_crypto", - web_crypto::WEB_CRYPTO_JS, - ) - .with_module("node:crypto", web_crypto::REEXPORT_JS) - .with_module("crypto", web_crypto::REEXPORT_JS) - .with_module("__wasm_rquickjs_builtin/vm", vm::VM_JS) - .with_module("node:vm", vm::REEXPORT_JS) - .with_module("vm", vm::REEXPORT_JS) - .with_module( + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Console) { + builtin_loader + .with_module("__wasm_rquickjs_builtin/console", console::CONSOLE_JS) + .with_module("node:console", console::CONSOLE_JS) + .with_module("console", console::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Timers) { + builtin_loader + .with_module("__wasm_rquickjs_builtin/timeout", timeout::TIMEOUT_JS) + .with_module("node:timers", timers::TIMERS_JS) + .with_module("timers", timers::REEXPORT_JS) + .with_module("node:timers/promises", timers::TIMERS_PROMISES_JS) + .with_module("timers/promises", timers::REEXPORT_PROMISES_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::NodeFetch) { + builtin_loader + .with_module("__wasm_rquickjs_builtin/http_blob", http::FETCH_BLOB_JS) + .with_module("__wasm_rquickjs_builtin/http_form_data", http::FORMDATA_JS) + .with_module("__wasm_rquickjs_builtin/http", http::HTTP_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Webstreams) { + builtin_loader + .with_module("__wasm_rquickjs_builtin/streams", webstreams::WEBSTREAMS_JS) + .with_module( + "__wasm_rquickjs_builtin/webstreams_wrapper", + webstreams::WEBSTREAMS_WRAPPER_JS, + ) + .with_module("node:stream/web", webstreams::REEXPORT_JS) + .with_module("stream/web", webstreams::REEXPORT_JS) + .with_module("web-streams-polyfill", webstreams::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::FormDataNode) { + builtin_loader.with_module("formdata-node", formdata_node::FORMDATA_NODE_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Encoding) { + builtin_loader.with_module("__wasm_rquickjs_builtin/encoding", encoding::ENCODING_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Intl) { + builtin_loader.with_module("__wasm_rquickjs_builtin/intl", intl::INTL_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Util) { + builtin_loader + .with_module("node:util", util::UTIL_JS) + .with_module("node:util/types", util::UTIL_TYPES_JS) + .with_module("util", util::BARE_UTIL_REEXPORT_JS) + .with_module("util/types", util::UTIL_TYPES_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Buffer) { + builtin_loader + .with_module("base64-js", base64::BASE64_JS) + .with_module("ieee754", ieee754::IEEE754_JS) + .with_module("node:buffer", buffer::BUFFER_JS) + .with_module("buffer", buffer::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Fs) { + builtin_loader + .with_module("node:fs", fs::FS_JS) + .with_module("fs", fs::REEXPORT_JS) + .with_module("node:fs/promises", fs::FS_PROMISES_JS) + .with_module("fs/promises", fs::REEXPORT_PROMISES_JS) + .with_module("internal/fs/promises", fs::REEXPORT_PROMISES_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Os) { + builtin_loader + .with_module("node:os", os::OS_JS) + .with_module("os", os::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Assert) { + builtin_loader + .with_module("node:assert", assert::ASSERT_JS) + .with_module("assert", assert::REEXPORT_JS) + .with_module("node:assert/strict", assert::ASSERT_STRICT_JS) + .with_module("assert/strict", assert::REEXPORT_STRICT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Querystring) { + builtin_loader + .with_module("node:querystring", querystring::QUERYSTRING_JS) + .with_module("querystring", querystring::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::ChildProcess) { + builtin_loader + .with_module("node:child_process", child_process::CHILD_PROCESS_JS) + .with_module("child_process", child_process::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::NodeTest) { + builtin_loader.with_module("node:test", node_test::TEST_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Module) { + builtin_loader + .with_module("node:module", module::MODULE_JS) + .with_module("module", module::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Process) { + builtin_loader + .with_module("node:process", process::PROCESS_JS) + .with_module("process", process::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Path) { + builtin_loader + .with_module("node:path", path::PATH_JS) + .with_module("path", path::REEXPORT_JS) + .with_module("node:path/posix", path::PATH_POSIX_REEXPORT_JS) + .with_module("path/posix", path::PATH_POSIX_REEXPORT_JS) + .with_module("node:path/win32", path::PATH_WIN32_REEXPORT_JS) + .with_module("path/win32", path::PATH_WIN32_REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Punycode) { + builtin_loader + .with_module("node:punycode", punycode::PUNYCODE_JS) + .with_module("punycode", punycode::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Url) { + builtin_loader + .with_module("__wasm_rquickjs_builtin/url", url::URL_JS) + .with_module("node:url", url::URL_JS) + .with_module("url", url::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Events) { + builtin_loader + .with_module("node:events", events::EVENTS_JS) + .with_module("events", events::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Stream) { + builtin_loader + .with_module("node:stream", stream::STREAM_JS) + .with_module("stream", stream::REEXPORT_JS) + .with_module("node:stream/promises", stream::STREAM_PROMISES_JS) + .with_module("stream/promises", stream::REEXPORT_PROMISES_JS) + .with_module("node:stream/consumers", stream::STREAM_CONSUMERS_JS) + .with_module("stream/consumers", stream::REEXPORT_CONSUMERS_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::StringDecoder) { + builtin_loader + .with_module("node:string_decoder", string_decoder::STRING_DECODER_JS) + .with_module("string_decoder", string_decoder::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::WebCrypto) { + builtin_loader + .with_module( + "__wasm_rquickjs_builtin/web_crypto", + web_crypto::WEB_CRYPTO_JS, + ) + .with_module("node:crypto", web_crypto::REEXPORT_JS) + .with_module("crypto", web_crypto::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Vm) { + builtin_loader + .with_module("__wasm_rquickjs_builtin/vm", vm::VM_JS) + .with_module("node:vm", vm::REEXPORT_JS) + .with_module("vm", vm::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::StructuredClone) { + builtin_loader.with_module( "__wasm_rquickjs_builtin/structured_clone", structured_clone::STRUCTURED_CLONE_JS, ) - .with_module("node:async_hooks", async_hooks::ASYNC_HOOKS_JS) - .with_module("async_hooks", async_hooks::REEXPORT_JS) - .with_module("node:cluster", cluster::CLUSTER_JS) - .with_module("cluster", cluster::REEXPORT_JS) - .with_module("node:constants", constants::CONSTANTS_JS) - .with_module("constants", constants::REEXPORT_JS) - .with_module("node:dgram", dgram::DGRAM_JS) - .with_module("dgram", dgram::REEXPORT_JS) - .with_module( - "node:diagnostics_channel", - diagnostics_channel::DIAGNOSTICS_CHANNEL_JS, - ) - .with_module("diagnostics_channel", diagnostics_channel::REEXPORT_JS) - .with_module("node:dns", dns::DNS_JS) - .with_module("dns", dns::REEXPORT_JS) - .with_module("node:dns/promises", dns::DNS_PROMISES_JS) - .with_module("dns/promises", dns::REEXPORT_PROMISES_JS) - .with_module("node:domain", domain::DOMAIN_JS) - .with_module("domain", domain::REEXPORT_JS) - .with_module( - "__wasm_rquickjs_builtin/node_http_server", - node_http::NODE_HTTP_SERVER_JS, - ) - .with_module("node:_http_common", node_http::HTTP_COMMON_JS) - .with_module("_http_common", node_http::HTTP_COMMON_JS) - .with_module("node:_http_agent", node_http::HTTP_AGENT_JS) - .with_module("_http_agent", node_http::HTTP_AGENT_JS) - .with_module("node:http", node_http::NODE_HTTP_JS) - .with_module("http", node_http::REEXPORT_JS) - .with_module("node:http2", http2::HTTP2_JS) - .with_module("http2", http2::REEXPORT_JS) - .with_module("node:https", https::HTTPS_JS) - .with_module("https", https::REEXPORT_JS) - .with_module("node:inspector", inspector::INSPECTOR_JS) - .with_module("inspector", inspector::REEXPORT_JS) - .with_module("node:net", net::NET_JS) - .with_module("net", net::REEXPORT_JS) - .with_module("node:perf_hooks", perf_hooks::PERF_HOOKS_JS) - .with_module("perf_hooks", perf_hooks::REEXPORT_JS) - .with_module("node:readline", readline::READLINE_JS) - .with_module("readline", readline::REEXPORT_JS) - .with_module("node:readline/promises", readline::READLINE_PROMISES_JS) - .with_module("readline/promises", readline::REEXPORT_PROMISES_JS) - .with_module("node:repl", repl::REPL_JS) - .with_module("repl", repl::REEXPORT_JS) - .with_module("node:console", console::CONSOLE_JS) - .with_module("console", console::REEXPORT_JS) - .with_module("node:trace_events", trace_events::TRACE_EVENTS_JS) - .with_module("trace_events", trace_events::REEXPORT_JS) - .with_module("node:tls", tls::TLS_JS) - .with_module("tls", tls::REEXPORT_JS) - .with_module("node:tty", tty::TTY_JS) - .with_module("tty", tty::REEXPORT_JS) - .with_module("node:v8", v8::V8_JS) - .with_module("v8", v8::REEXPORT_JS) - .with_module("node:worker_threads", worker_threads::WORKER_THREADS_JS) - .with_module("worker_threads", worker_threads::REEXPORT_JS) - .with_module("node:zlib", zlib::ZLIB_JS) - .with_module("zlib", zlib::REEXPORT_JS) - .with_module("node:sqlite", sqlite::SQLITE_JS); + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::AsyncHooks) { + builtin_loader + .with_module("node:async_hooks", async_hooks::ASYNC_HOOKS_JS) + .with_module("async_hooks", async_hooks::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Cluster) { + builtin_loader + .with_module("node:cluster", cluster::CLUSTER_JS) + .with_module("cluster", cluster::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Constants) { + builtin_loader + .with_module("node:constants", constants::CONSTANTS_JS) + .with_module("constants", constants::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Dgram) { + builtin_loader + .with_module("node:dgram", dgram::DGRAM_JS) + .with_module("dgram", dgram::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::DiagnosticsChannel) { + builtin_loader + .with_module( + "node:diagnostics_channel", + diagnostics_channel::DIAGNOSTICS_CHANNEL_JS, + ) + .with_module("diagnostics_channel", diagnostics_channel::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Dns) { + builtin_loader + .with_module("node:dns", dns::DNS_JS) + .with_module("dns", dns::REEXPORT_JS) + .with_module("node:dns/promises", dns::DNS_PROMISES_JS) + .with_module("dns/promises", dns::REEXPORT_PROMISES_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Domain) { + builtin_loader + .with_module("node:domain", domain::DOMAIN_JS) + .with_module("domain", domain::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::NodeHttp) { + builtin_loader + .with_module( + "__wasm_rquickjs_builtin/node_http_server", + node_http::NODE_HTTP_SERVER_JS, + ) + .with_module("node:_http_common", node_http::HTTP_COMMON_JS) + .with_module("_http_common", node_http::HTTP_COMMON_JS) + .with_module("node:_http_agent", node_http::HTTP_AGENT_JS) + .with_module("_http_agent", node_http::HTTP_AGENT_JS) + .with_module("node:http", node_http::NODE_HTTP_JS) + .with_module("http", node_http::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Http2) { + builtin_loader + .with_module("node:http2", http2::HTTP2_JS) + .with_module("http2", http2::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Https) { + builtin_loader + .with_module("node:https", https::HTTPS_JS) + .with_module("https", https::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Inspector) { + builtin_loader + .with_module("node:inspector", inspector::INSPECTOR_JS) + .with_module("inspector", inspector::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Net) { + builtin_loader + .with_module("node:net", net::NET_JS) + .with_module("net", net::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::PerfHooks) { + builtin_loader + .with_module("node:perf_hooks", perf_hooks::PERF_HOOKS_JS) + .with_module("perf_hooks", perf_hooks::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Readline) { + builtin_loader + .with_module("node:readline", readline::READLINE_JS) + .with_module("readline", readline::REEXPORT_JS) + .with_module("node:readline/promises", readline::READLINE_PROMISES_JS) + .with_module("readline/promises", readline::REEXPORT_PROMISES_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Repl) { + builtin_loader + .with_module("node:repl", repl::REPL_JS) + .with_module("repl", repl::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::TraceEvents) { + builtin_loader + .with_module("node:trace_events", trace_events::TRACE_EVENTS_JS) + .with_module("trace_events", trace_events::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Tls) { + builtin_loader + .with_module("node:tls", tls::TLS_JS) + .with_module("tls", tls::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Tty) { + builtin_loader + .with_module("node:tty", tty::TTY_JS) + .with_module("tty", tty::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::V8) { + builtin_loader + .with_module("node:v8", v8::V8_JS) + .with_module("v8", v8::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::WorkerThreads) { + builtin_loader + .with_module("node:worker_threads", worker_threads::WORKER_THREADS_JS) + .with_module("worker_threads", worker_threads::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Zlib) { + builtin_loader + .with_module("node:zlib", zlib::ZLIB_JS) + .with_module("zlib", zlib::REEXPORT_JS) + } else { + builtin_loader + }; + + let builtin_loader = if cap(Capability::Sqlite) { + builtin_loader.with_module("node:sqlite", sqlite::SQLITE_JS) + } else { + builtin_loader + }; let builtin_loader = builtin_loader.with_module("wasm-rquickjs:execution", execution::EXECUTION_JS); #[cfg(feature = "golem")] - let builtin_loader = builtin_loader.with_module( - "__wasm_rquickjs_builtin/diagnostics_channel_golem", - diagnostics_channel::DIAGNOSTICS_CHANNEL_GOLEM_JS, - ); + let builtin_loader = if cap(Capability::DiagnosticsChannel) { + builtin_loader.with_module( + "__wasm_rquickjs_builtin/diagnostics_channel_golem", + diagnostics_channel::DIAGNOSTICS_CHANNEL_GOLEM_JS, + ) + } else { + builtin_loader + }; #[cfg(feature = "websocket")] - let builtin_loader = - builtin_loader.with_module("__wasm_rquickjs_builtin/websocket", websocket::WEBSOCKET_JS); + let builtin_loader = if cap(Capability::Websocket) { + builtin_loader.with_module("__wasm_rquickjs_builtin/websocket", websocket::WEBSOCKET_JS) + } else { + builtin_loader + }; (native_loader, builtin_loader, internal::module_loader()) } pub fn wire_builtins() -> String { let mut result = String::new(); - writeln!(result, "{}", events::WIRE_JS).unwrap(); - writeln!(result, "{}", abort_controller::WIRE_JS).unwrap(); - writeln!(result, "{}", base64::WIRE_JS).unwrap(); - writeln!(result, "{}", buffer::WIRE_JS).unwrap(); - writeln!(result, "{}", console::WIRE_JS).unwrap(); - writeln!(result, "{}", timeout::WIRE_JS).unwrap(); - writeln!(result, "{}", gc::WIRE_JS).unwrap(); - writeln!(result, "{}", http::WIRE_JS).unwrap(); - writeln!(result, "{}", webstreams::WIRE_JS).unwrap(); - writeln!(result, "{}", encoding::WIRE_JS).unwrap(); - writeln!(result, "{}", intl::WIRE_JS).unwrap(); - writeln!(result, "{}", url::WIRE_JS).unwrap(); - writeln!(result, "{}", web_crypto::WIRE_JS).unwrap(); - writeln!(result, "{}", process::WIRE_JS).unwrap(); - writeln!(result, "{}", structured_clone::WIRE_JS).unwrap(); - writeln!(result, "{}", module::WIRE_JS).unwrap(); - writeln!(result, "{}", worker_threads::WIRE_JS).unwrap(); + + // Each `WIRE_JS` block installs that capability's globals (e.g. `Buffer`, + // `fetch`, `EventTarget`). Skipping the block means the global is never + // installed, which lets the JS-level capability scanner / wasm-level DCE + // drop both the corresponding `WIRE_JS` strings and any host imports they + // would have transitively kept alive. + + if cap(Capability::Events) { + writeln!(result, "{}", events::WIRE_JS).unwrap(); + } + if cap(Capability::AbortController) { + writeln!(result, "{}", abort_controller::WIRE_JS).unwrap(); + } + if cap(Capability::Base64) { + writeln!(result, "{}", base64::WIRE_JS).unwrap(); + } + if cap(Capability::Buffer) { + writeln!(result, "{}", buffer::WIRE_JS).unwrap(); + } + if cap(Capability::Console) { + writeln!(result, "{}", console::WIRE_JS).unwrap(); + } + if cap(Capability::Timers) { + writeln!(result, "{}", timeout::WIRE_JS).unwrap(); + } + if cap(Capability::Gc) { + writeln!(result, "{}", gc::WIRE_JS).unwrap(); + } + if cap(Capability::NodeFetch) { + writeln!(result, "{}", http::WIRE_JS).unwrap(); + } + if cap(Capability::Webstreams) { + writeln!(result, "{}", webstreams::WIRE_JS).unwrap(); + } + if cap(Capability::Encoding) { + writeln!(result, "{}", encoding::WIRE_JS).unwrap(); + } + if cap(Capability::Intl) { + writeln!(result, "{}", intl::WIRE_JS).unwrap(); + } + if cap(Capability::Url) { + writeln!(result, "{}", url::WIRE_JS).unwrap(); + } + if cap(Capability::WebCrypto) { + writeln!(result, "{}", web_crypto::WIRE_JS).unwrap(); + } + if cap(Capability::Process) { + writeln!(result, "{}", process::WIRE_JS).unwrap(); + } + if cap(Capability::StructuredClone) { + writeln!(result, "{}", structured_clone::WIRE_JS).unwrap(); + } + if cap(Capability::Module) { + writeln!(result, "{}", module::WIRE_JS).unwrap(); + } + if cap(Capability::WorkerThreads) { + writeln!(result, "{}", worker_threads::WIRE_JS).unwrap(); + } writeln!(result, "globalThis.global = globalThis;").unwrap(); writeln!(result, "globalThis.self = globalThis;").unwrap(); writeln!( @@ -585,10 +1342,14 @@ pub fn wire_builtins() -> String { .unwrap(); #[cfg(feature = "golem")] - writeln!(result, "{}", diagnostics_channel::GOLEM_WIRE_JS).unwrap(); + if cap(Capability::DiagnosticsChannel) { + writeln!(result, "{}", diagnostics_channel::GOLEM_WIRE_JS).unwrap(); + } #[cfg(feature = "websocket")] - writeln!(result, "{}", websocket::WIRE_JS).unwrap(); + if cap(Capability::Websocket) { + writeln!(result, "{}", websocket::WIRE_JS).unwrap(); + } result } diff --git a/crates/wasm-rquickjs/skeleton/src/capabilities.rs b/crates/wasm-rquickjs/skeleton/src/capabilities.rs new file mode 100644 index 000000000..3ad9b5b5a --- /dev/null +++ b/crates/wasm-rquickjs/skeleton/src/capabilities.rs @@ -0,0 +1,258 @@ +//! Per-capability runtime gates. +//! +//! The skeleton ships with all builtins compiled in. Each builtin's registration +//! and global wiring is gated by a single bit in [`CAPABILITY_GATES_SLOT`]. By +//! default every bit is set, so every capability is wired up and behaves as if no +//! trimming were taking place. +//! +//! After the skeleton has been compiled to wasm, the `wasm-rquickjs` host tooling +//! can patch the bitset inside the slot to clear bits for capabilities the user's +//! JavaScript provably does not need (see `wasm_rquickjs::inject`). Once a bit is +//! cleared, the corresponding native module is not registered, the wrapper JS is +//! not loaded, and the global wiring code is skipped. Combined with a downstream +//! wasm-level dead-code-elimination pass that drops unreferenced WIT imports, +//! this lets a precompiled base image shed the WASI surface (filesystem, sockets, +//! http, ...) of any builtin that the user app does not actually use. +//! +//! ## Slot layout +//! +//! The slot is a fixed 40-byte structure embedded as a `#[link_section]` static +//! so it ends up in a data segment that the patch tool can locate by magic: +//! +//! ```text +//! [MAGIC 16 bytes] +//! [GATES u64 LE 8 bytes] ← patchable bitset, bit `i` = capability with index `i` +//! [END_MAGIC 16 bytes] +//! ``` +//! +//! All reads of the bitset go through `core::ptr::read_volatile` to defeat +//! constant-folding: the value is decided post-compile, not at LLVM time. +//! +//! ## Adding capabilities +//! +//! The gates use a `u64`, giving us 64 slots. The [`Capability`] enum is `repr(u8)` +//! with explicit discriminants so adding/removing variants does not silently shift +//! the meaning of existing bits. The discriminants are kept in sync with the +//! `Capability` enum exposed by the host-side `wasm-rquickjs::capability_scan` +//! module by name. + +use core::sync::atomic::{AtomicBool, Ordering}; + +/// Magic prefix identifying the capability-gates slot in the wasm data section. +pub const CAPABILITY_GATES_MAGIC: &[u8; 16] = b"WASM_RQJS_CAPS\x01\x00"; + +/// Magic suffix used to validate slot integrity after patching. +pub const CAPABILITY_GATES_END_MAGIC: &[u8; 16] = b"WASM_RQJS_CAPSND"; + +/// Total slot size: MAGIC(16) + GATES(8) + END_MAGIC(16). +pub const CAPABILITY_GATES_SLOT_SIZE: usize = 40; + +/// Default value of every gate bit before patching: all 64 capabilities enabled. +const DEFAULT_GATES: u64 = u64::MAX; + +/// Build the capability-gates slot literal at compile time. +const fn build_capability_gates_slot() -> [u8; CAPABILITY_GATES_SLOT_SIZE] { + let mut slot = [0u8; CAPABILITY_GATES_SLOT_SIZE]; + let mut i = 0; + while i < 16 { + slot[i] = CAPABILITY_GATES_MAGIC[i]; + i += 1; + } + let bytes = DEFAULT_GATES.to_le_bytes(); + let mut j = 0; + while j < 8 { + slot[16 + j] = bytes[j]; + j += 1; + } + let mut k = 0; + while k < 16 { + slot[24 + k] = CAPABILITY_GATES_END_MAGIC[k]; + k += 1; + } + slot +} + +/// The patchable gate slot. Lives in a dedicated link section so that the +/// host-side patch tool can locate it inside the data section by magic and +/// flip individual bits before the wasm component is shipped. +/// +/// `#[unsafe(no_mangle)]` keeps the symbol stable; `static` (not `const`) +/// guarantees the bytes end up in a data segment instead of being inlined. +#[unsafe(no_mangle)] +#[unsafe(link_section = ".wasm_rquickjs_capability_gates")] +pub static CAPABILITY_GATES_SLOT: [u8; CAPABILITY_GATES_SLOT_SIZE] = + build_capability_gates_slot(); + +/// Read the patched gate bitset from the slot via volatile reads to prevent +/// LLVM from constant-folding the default value into callers. +fn read_gates() -> u64 { + let ptr = CAPABILITY_GATES_SLOT.as_ptr(); + let mut bytes = [0u8; 8]; + // SAFETY: `CAPABILITY_GATES_SLOT` is a static array of length + // `CAPABILITY_GATES_SLOT_SIZE`, so reading 8 bytes starting at offset 16 is + // in bounds and properly aligned for `u8`. + unsafe { + let mut i = 0; + while i < 8 { + bytes[i] = core::ptr::read_volatile(ptr.add(16 + i)); + i += 1; + } + } + u64::from_le_bytes(bytes) +} + +/// Cached snapshot of the gates: we read the slot via volatile loads exactly +/// once, then cache the resulting bitset because the value is fixed for the +/// lifetime of the wasm instance (the slot is patched offline, before the wasm +/// is instantiated). The cache is purely a performance hint; correctness comes +/// from the volatile read inside [`read_gates`]. +fn cached_gates() -> u64 { + static INIT: AtomicBool = AtomicBool::new(false); + static mut GATES: u64 = 0; + // SAFETY: `INIT` and `GATES` are accessed only by the runtime at startup, + // before any other thread can touch the QuickJS context. We are wasm, + // single-threaded. + unsafe { + if !INIT.load(Ordering::Relaxed) { + GATES = read_gates(); + INIT.store(true, Ordering::Relaxed); + } + GATES + } +} + +/// Returns `true` if the bit corresponding to `cap` is set in the gates slot. +#[inline] +pub fn is_enabled(cap: Capability) -> bool { + let bit = cap as u8; + (cached_gates() >> bit) & 1 == 1 +} + +/// Identifiers for each builtin capability the skeleton can be asked to enable +/// or disable at runtime. +/// +/// Discriminants are explicit and **must remain stable**: each value is also a +/// bit index into the gates bitset patched into the wasm by the host tooling. +/// Keep this in sync with `wasm_rquickjs::capability_scan::Capability` (same +/// names, same set, same discriminants up to ordering — the host tool uses +/// [`Capability::from_marker_name`] to resolve names rather than relying on +/// numeric ordering across crates). +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[allow(dead_code)] +pub enum Capability { + AbortController = 0, + Assert = 1, + AsyncHooks = 2, + Base64 = 3, + Buffer = 4, + ChildProcess = 5, + Cluster = 6, + Console = 7, + Constants = 8, + Dgram = 9, + DiagnosticsChannel = 10, + Dns = 11, + Domain = 12, + Encoding = 13, + Events = 14, + FormDataNode = 15, + Fs = 16, + Gc = 17, + Http2 = 18, + Https = 19, + Inspector = 20, + Intl = 21, + Module = 22, + Net = 23, + NodeFetch = 24, + NodeHttp = 25, + NodeTest = 26, + Os = 27, + Path = 28, + PerfHooks = 29, + Process = 30, + Punycode = 31, + Querystring = 32, + Readline = 33, + Repl = 34, + Sqlite = 35, + Stream = 36, + StringDecoder = 37, + StructuredClone = 38, + Timers = 39, + Tls = 40, + TraceEvents = 41, + Tty = 42, + Url = 43, + Util = 44, + V8 = 45, + Vm = 46, + WebCrypto = 47, + Websocket = 48, + Webstreams = 49, + WorkerThreads = 50, + Zlib = 51, +} + +impl Capability { + /// Stable lower-snake-case name used by the host scanner / CLI. + #[allow(dead_code)] + pub fn marker_name(self) -> &'static str { + use Capability::*; + match self { + AbortController => "abort_controller", + Assert => "assert", + AsyncHooks => "async_hooks", + Base64 => "base64", + Buffer => "buffer", + ChildProcess => "child_process", + Cluster => "cluster", + Console => "console", + Constants => "constants", + Dgram => "dgram", + DiagnosticsChannel => "diagnostics_channel", + Dns => "dns", + Domain => "domain", + Encoding => "encoding", + Events => "events", + FormDataNode => "formdata_node", + Fs => "fs", + Gc => "gc", + Http2 => "http2", + Https => "https", + Inspector => "inspector", + Intl => "intl", + Module => "module", + Net => "net", + NodeFetch => "node_fetch", + NodeHttp => "node_http", + NodeTest => "node_test", + Os => "os", + Path => "path", + PerfHooks => "perf_hooks", + Process => "process", + Punycode => "punycode", + Querystring => "querystring", + Readline => "readline", + Repl => "repl", + Sqlite => "sqlite", + Stream => "stream", + StringDecoder => "string_decoder", + StructuredClone => "structured_clone", + Timers => "timers", + Tls => "tls", + TraceEvents => "trace_events", + Tty => "tty", + Url => "url", + Util => "util", + V8 => "v8", + Vm => "vm", + WebCrypto => "web_crypto", + Websocket => "websocket", + Webstreams => "webstreams", + WorkerThreads => "worker_threads", + Zlib => "zlib", + } + } +} diff --git a/crates/wasm-rquickjs/skeleton/src/lib.rs b/crates/wasm-rquickjs/skeleton/src/lib.rs index 81fef177d..b228d4b1c 100644 --- a/crates/wasm-rquickjs/skeleton/src/lib.rs +++ b/crates/wasm-rquickjs/skeleton/src/lib.rs @@ -1,6 +1,7 @@ // Empty file, to be generated mod builtin; +pub mod capabilities; pub mod internal; mod modules; pub mod wrappers; diff --git a/crates/wasm-rquickjs/src/capability_scan.rs b/crates/wasm-rquickjs/src/capability_scan.rs new file mode 100644 index 000000000..d41027445 --- /dev/null +++ b/crates/wasm-rquickjs/src/capability_scan.rs @@ -0,0 +1,1370 @@ +//! Static scan of a JavaScript source to determine the set of skeleton-builtin +//! "capabilities" it actually uses. +//! +//! This is the input side of the Layer-3 plan: the CLI runs this scan, then patches +//! `__wrjs_cap_` marker functions in the prebuilt wasm so that registration of +//! unused builtins becomes dead code, which downstream DCE/import-strip tooling can +//! then eliminate. +//! +//! ## What the scan sees +//! +//! - `import` / `export` declarations with literal sources. +//! - Dynamic `import()`. +//! - `require()` calls (CJS bridge), regardless of nesting. +//! - Reads of well-known global identifiers (`Buffer`, `fetch`, `crypto`, `URL`, +//! `TextEncoder`, `setTimeout`, `process`, …) and member-expression bases that +//! start from one (`crypto.subtle`, `Intl.DateTimeFormat`). +//! +//! ## What the scan flags but cannot resolve +//! +//! - `require(varName)` / `import(expr)` with a non-literal argument. +//! - `eval(...)`, `new Function(...)`. +//! - `vm.runInThisContext` / `vm.runInNewContext` / `vm.compileFunction`. +//! +//! When any of these appear, the scanner sets `has_dynamic = true`. The CLI policy +//! is to keep all capabilities in that case unless the user opts in to aggressive +//! trimming via configuration / magic comments. +//! +//! ## What the scan deliberately does not do (yet) +//! +//! - **Scope tracking.** A user-defined local named `crypto` will be treated as +//! touching `WebCrypto`. This is conservative (we may keep a capability we don't +//! need) — never the other way around. +//! - **Cross-module analysis.** Each source unit is scanned independently. Relative +//! imports between user-supplied JS files are not followed; their specifiers will +//! surface as warnings of kind `RelativeImport`. + +use std::collections::{BTreeSet, VecDeque}; + +use camino::{Utf8Path, Utf8PathBuf}; +use oxc_allocator::Allocator; +use oxc_ast::ast::*; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_span::{SourceType, Span}; + +/// One capability ≈ one skeleton built-in module (and the WIT imports it implies). +/// +/// The variants intentionally mirror the modules registered in +/// `crates/wasm-rquickjs/skeleton/src/builtin/mod.rs` so the same identifier can be +/// reused later for `__wrjs_cap_` marker-function names. +/// +/// The explicit discriminants are also used as bit indices into the +/// `capability gates` slot patched by [`crate::patch_capability_gates_in_bytes`] +/// — they MUST stay in sync with the `Capability` enum in +/// `crates/wasm-rquickjs/skeleton/src/capabilities.rs`. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Capability { + AbortController = 0, + Assert = 1, + AsyncHooks = 2, + Base64 = 3, + Buffer = 4, + ChildProcess = 5, + Cluster = 6, + Console = 7, + Constants = 8, + Dgram = 9, + DiagnosticsChannel = 10, + Dns = 11, + Domain = 12, + Encoding = 13, + Events = 14, + FormDataNode = 15, + Fs = 16, + Gc = 17, + Http2 = 18, + Https = 19, + Inspector = 20, + Intl = 21, + Module = 22, + Net = 23, + NodeFetch = 24, + NodeHttp = 25, + NodeTest = 26, + Os = 27, + Path = 28, + PerfHooks = 29, + Process = 30, + Punycode = 31, + Querystring = 32, + Readline = 33, + Repl = 34, + Sqlite = 35, + Stream = 36, + StringDecoder = 37, + StructuredClone = 38, + Timers = 39, + Tls = 40, + TraceEvents = 41, + Tty = 42, + Url = 43, + Util = 44, + V8 = 45, + Vm = 46, + WebCrypto = 47, + Websocket = 48, + Webstreams = 49, + WorkerThreads = 50, + Zlib = 51, +} + +/// Every capability the scanner knows about, in declaration order. +/// Used by the policy layer when `has_dynamic` falls back to "enable everything". +pub const ALL_CAPABILITIES: &[Capability] = &[ + Capability::AbortController, + Capability::Assert, + Capability::AsyncHooks, + Capability::Base64, + Capability::Buffer, + Capability::ChildProcess, + Capability::Cluster, + Capability::Console, + Capability::Constants, + Capability::Dgram, + Capability::DiagnosticsChannel, + Capability::Dns, + Capability::Domain, + Capability::Encoding, + Capability::Events, + Capability::FormDataNode, + Capability::Fs, + Capability::Gc, + Capability::Http2, + Capability::Https, + Capability::Inspector, + Capability::Intl, + Capability::Module, + Capability::Net, + Capability::NodeFetch, + Capability::NodeHttp, + Capability::NodeTest, + Capability::Os, + Capability::Path, + Capability::PerfHooks, + Capability::Process, + Capability::Punycode, + Capability::Querystring, + Capability::Readline, + Capability::Repl, + Capability::Sqlite, + Capability::Stream, + Capability::StringDecoder, + Capability::StructuredClone, + Capability::Timers, + Capability::Tls, + Capability::TraceEvents, + Capability::Tty, + Capability::Url, + Capability::Util, + Capability::V8, + Capability::Vm, + Capability::WebCrypto, + Capability::Websocket, + Capability::Webstreams, + Capability::WorkerThreads, + Capability::Zlib, +]; + +impl Capability { + /// Look up a capability by its marker name (the inverse of [`Capability::marker_name`]). + pub fn from_marker_name(s: &str) -> Option { + ALL_CAPABILITIES + .iter() + .copied() + .find(|c| c.marker_name() == s) + } + + /// Bit index in the capability-gates slot (matches the variant's + /// discriminant). Used by [`crate::patch_capability_gates_in_bytes`] to + /// flip the bit corresponding to this capability. + pub fn bit_index(self) -> u8 { + self as u8 + } + + /// Stable lower-snake-case name used for marker symbols (`__wrjs_cap_`). + pub fn marker_name(self) -> &'static str { + use Capability::*; + match self { + AbortController => "abort_controller", + Assert => "assert", + AsyncHooks => "async_hooks", + Base64 => "base64", + Buffer => "buffer", + ChildProcess => "child_process", + Cluster => "cluster", + Console => "console", + Constants => "constants", + Dgram => "dgram", + DiagnosticsChannel => "diagnostics_channel", + Dns => "dns", + Domain => "domain", + Encoding => "encoding", + Events => "events", + FormDataNode => "formdata_node", + Fs => "fs", + Gc => "gc", + Http2 => "http2", + Https => "https", + Inspector => "inspector", + Intl => "intl", + Module => "module", + Net => "net", + NodeFetch => "node_fetch", + NodeHttp => "node_http", + NodeTest => "node_test", + Os => "os", + Path => "path", + PerfHooks => "perf_hooks", + Process => "process", + Punycode => "punycode", + Querystring => "querystring", + Readline => "readline", + Repl => "repl", + Sqlite => "sqlite", + Stream => "stream", + StringDecoder => "string_decoder", + StructuredClone => "structured_clone", + Timers => "timers", + Tls => "tls", + TraceEvents => "trace_events", + Tty => "tty", + Url => "url", + Util => "util", + V8 => "v8", + Vm => "vm", + WebCrypto => "web_crypto", + Websocket => "websocket", + Webstreams => "webstreams", + WorkerThreads => "worker_threads", + Zlib => "zlib", + } + } +} + +/// Build the 64-bit `enabled_bits` value expected by +/// [`crate::patch_capability_gates_in_bytes`] from an iterable of enabled +/// capabilities. +pub fn enabled_bits>(enabled: I) -> u64 { + let mut bits = 0u64; + for c in enabled { + bits |= 1u64 << c.bit_index(); + } + bits +} + +/// Result of scanning one or more JS source units. +#[derive(Debug, Clone, Default)] +pub struct ScanResult { + /// Capabilities the scanner is confident the JS uses. + pub used: BTreeSet, + /// Bare specifiers that did not match any known builtin and that don't look + /// like WIT-style component imports (e.g. npm packages, ad-hoc modules). + /// Recorded for diagnostics; they don't drive trimming. + pub unknown_specifiers: BTreeSet, + /// Fully-qualified WIT-style specifiers (`:/(@)?`). + /// These are component-model imports satisfied by the host, not skeleton + /// builtins. Recorded so callers can distinguish them from real npm-style + /// "unknown" specifiers. + pub wit_specifiers: BTreeSet, + /// Diagnostic findings about patterns the scanner cannot fully resolve. + pub warnings: Vec, + /// `true` if the scan encountered any pattern that could change the module + /// graph at runtime (`require(varName)`, `import(expr)`, `eval`, `new Function`, + /// `vm.run*`). When set, the CLI's default policy is to disable trimming. + pub has_dynamic: bool, +} + +#[derive(Debug, Clone)] +pub struct Warning { + pub kind: WarningKind, + pub source: String, + pub line: u32, + pub column: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WarningKind { + /// `require(expr)` / `require(...nonStringLiteral...)`. + DynamicRequire, + /// `import(expr)` with a non-literal argument. + DynamicImport, + /// Direct `eval(...)` call. + Eval, + /// `new Function(...)`. + NewFunction, + /// `vm.runInThisContext`, `vm.runInNewContext`, `vm.runInContext`, + /// `vm.compileFunction`. Implies `Capability::Vm` regardless. + VmEval, + /// A relative or absolute path import seen during single-file scanning. + /// Disappears in `scan_entry_point` if the target file resolves and parses. + RelativeImport(String), + /// A relative/absolute import that could not be resolved on disk during + /// transitive scanning. + UnresolvableImport(String), +} + +/// Scan a single source unit. Recognizes JavaScript and TypeScript files based +/// on the path extension (`.cjs` → CJS; `.ts`/`.cts`/`.mts`/`.tsx` → TS; others +/// → ESM JS). +pub fn scan_module(path: &Utf8Path, source: &str) -> ScanResult { + let p = path.as_str(); + let source_type = if p.ends_with(".cjs") { + SourceType::cjs() + } else if p.ends_with(".tsx") { + SourceType::tsx() + } else if p.ends_with(".cts") || p.ends_with(".mts") || p.ends_with(".ts") { + SourceType::ts() + } else { + SourceType::mjs() + }; + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, source_type).parse(); + let mut scanner = Scanner::new(source); + scanner.visit_program(&parsed.program); + scanner.into_result() +} + +/// Scan multiple source units and union their findings. +pub fn scan_modules(units: I) -> ScanResult +where + I: IntoIterator, +{ + let mut combined = ScanResult::default(); + for (path, source) in units { + let r = scan_module(path.as_ref(), &source); + combined.used.extend(r.used); + combined.unknown_specifiers.extend(r.unknown_specifiers); + combined.wit_specifiers.extend(r.wit_specifiers); + combined.warnings.extend(r.warnings); + combined.has_dynamic |= r.has_dynamic; + } + combined +} + +/// Helper newtype so callers can pass owned or borrowed paths into `scan_modules`. +pub struct Utf8PathSource(Utf8PathBuf); + +impl Utf8PathSource { + pub fn new(p: impl Into) -> Self { + Self(p.into()) + } +} + +impl AsRef for Utf8PathSource { + fn as_ref(&self) -> &Utf8Path { + &self.0 + } +} + +/// Scan an entry-point JS file and transitively follow its relative imports. +/// +/// Each `import`/`export … from`/`require()`/`import()` whose specifier starts +/// with `./`, `../`, or `/` is resolved on disk (trying ``, `.js`, +/// `.mjs`, `.cjs`, `/index.js`, `/index.mjs`, +/// `/index.cjs`) and the target file is scanned recursively. +/// +/// Bare specifiers (`node:fs`, `lodash`, `wasi:foo/bar`) are *not* followed; +/// they're handled by `record_specifier` as before. +/// +/// Cycles and re-imports are deduplicated via a visited-paths set. +/// +/// On any IO/parse failure for a relative target, an `UnresolvableImport` warning +/// is emitted but scanning continues. +pub fn scan_entry_point(entry: &Utf8Path) -> ScanResult { + let mut combined = ScanResult::default(); + let mut visited: BTreeSet = BTreeSet::new(); + let mut queue: VecDeque = VecDeque::new(); + queue.push_back(entry.to_path_buf()); + + while let Some(path) = queue.pop_front() { + let canon = match path.canonicalize_utf8() { + Ok(p) => p, + Err(e) => { + combined.warnings.push(Warning { + kind: WarningKind::UnresolvableImport(path.to_string()), + source: format!("{e}"), + line: 0, + column: 0, + }); + continue; + } + }; + if !visited.insert(canon.clone()) { + continue; + } + let source = match std::fs::read_to_string(canon.as_std_path()) { + Ok(s) => s, + Err(e) => { + combined.warnings.push(Warning { + kind: WarningKind::UnresolvableImport(canon.to_string()), + source: format!("{e}"), + line: 0, + column: 0, + }); + continue; + } + }; + + let r = scan_module(canon.as_path(), &source); + combined.used.extend(r.used); + combined.unknown_specifiers.extend(r.unknown_specifiers); + combined.wit_specifiers.extend(r.wit_specifiers); + combined.has_dynamic |= r.has_dynamic; + + // Walk the warnings: try to resolve `RelativeImport` targets and queue them; + // pass through everything else. + let parent_dir = canon + .parent() + .unwrap_or_else(|| Utf8Path::new("")) + .to_path_buf(); + for w in r.warnings { + match &w.kind { + WarningKind::RelativeImport(spec) => match resolve_relative(&parent_dir, spec) { + Some(resolved) => queue.push_back(resolved), + None => combined.warnings.push(Warning { + kind: WarningKind::UnresolvableImport(spec.clone()), + source: w.source, + line: w.line, + column: w.column, + }), + }, + _ => combined.warnings.push(w), + } + } + } + + combined +} + +/// Try to resolve a relative-or-absolute `spec` (as it appeared in JS) against +/// the importing file's `parent_dir`, exhausting common Node.js extensions and +/// `index` lookups. +fn resolve_relative(parent_dir: &Utf8Path, spec: &str) -> Option { + // Absolute paths are taken as-is; relative ones join against parent_dir. + let base = if spec.starts_with('/') { + Utf8PathBuf::from(spec) + } else { + parent_dir.join(spec) + }; + + // Try literal first; then with each extension; then index.* under it. + let candidates = [ + base.clone(), + with_appended(&base, ".js"), + with_appended(&base, ".mjs"), + with_appended(&base, ".cjs"), + with_appended(&base, ".json"), + base.join("index.js"), + base.join("index.mjs"), + base.join("index.cjs"), + ]; + candidates.into_iter().find(|c| c.is_file()) +} + +fn with_appended(p: &Utf8Path, suffix: &str) -> Utf8PathBuf { + let mut s = p.as_str().to_string(); + s.push_str(suffix); + Utf8PathBuf::from(s) +} + +// ------------------------------------------------------------------------------------------------- +// Specifier → Capability mapping +// ------------------------------------------------------------------------------------------------- + +/// Map a literal module specifier to a capability. Returns `None` for unknown +/// specifiers (npm packages, relative paths, etc.). +fn spec_to_cap(spec: &str) -> Option { + let stripped = spec.strip_prefix("node:").unwrap_or(spec); + let head = stripped.split_once('/').map(|x| x.0).unwrap_or(stripped); + use Capability::*; + Some(match head { + "abort_controller" => AbortController, + "assert" => Assert, + "async_hooks" => AsyncHooks, + "buffer" | "base64-js" | "ieee754" => Buffer, + "child_process" => ChildProcess, + "cluster" => Cluster, + "console" => Console, + "constants" => Constants, + "crypto" => WebCrypto, + "dgram" => Dgram, + "diagnostics_channel" => DiagnosticsChannel, + "dns" => Dns, + "domain" => Domain, + "events" => Events, + "formdata-node" => FormDataNode, + "fs" => Fs, + "http" | "_http_common" | "_http_agent" => NodeHttp, + "http2" => Http2, + "https" => Https, + "inspector" => Inspector, + "module" => Module, + "net" => Net, + "os" => Os, + "path" => Path, + "perf_hooks" => PerfHooks, + "process" => Process, + "punycode" => Punycode, + "querystring" => Querystring, + "readline" => Readline, + "repl" => Repl, + "sqlite" => Sqlite, + "stream" | "web-streams-polyfill" => Stream, + "string_decoder" => StringDecoder, + "test" => NodeTest, + "timers" => Timers, + "tls" => Tls, + "trace_events" => TraceEvents, + "tty" => Tty, + "url" => Url, + "util" => Util, + "v8" => V8, + "vm" => Vm, + "worker_threads" => WorkerThreads, + "zlib" => Zlib, + _ => return None, + }) +} + +/// True for fully-qualified WIT-style specifiers like `wasi:random/random@0.2.3` +/// or `quickjs:example3/iface`. Shape: `:/(@)?` where each +/// segment is an identifier-like token (alphanumerics with `-`/`_`/`.`). +/// +/// Intentionally narrow so URL schemes (`data:`, `http://`, `file:`) don't match. +fn is_wit_style_specifier(spec: &str) -> bool { + fn is_ident(s: &str) -> bool { + !s.is_empty() + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) + } + let Some((ns, rest)) = spec.split_once(':') else { + return false; + }; + if !is_ident(ns) { + return false; + } + let Some((pkg, iface_and_ver)) = rest.split_once('/') else { + return false; + }; + if !is_ident(pkg) { + return false; + } + let iface = iface_and_ver + .split_once('@') + .map(|(i, _ver)| i) + .unwrap_or(iface_and_ver); + is_ident(iface) +} + +/// Map a global identifier name to the capability whose `WIRE_JS` installs it. +/// +/// This table was audited against `crates/wasm-rquickjs/skeleton/src/builtin/*.rs` +/// — every entry corresponds to an actual `globalThis.X = …` line in some +/// builtin's wire script. We don't include QuickJS-native globals like +/// `queueMicrotask`, `Promise`, `Map`, etc. because they're always present +/// regardless of which capabilities are enabled. +/// +/// Conservative bias: a user-defined local shadowing one of these names will +/// still trigger inclusion of the corresponding capability (no scope tracking). +fn global_to_cap(name: &str) -> Option { + use Capability::*; + Some(match name { + // abort_controller WIRE_JS + "AbortController" | "AbortSignal" | "DOMException" => AbortController, + // base64 WIRE_JS + "atob" | "btoa" => Base64, + // buffer WIRE_JS (note: also lowercase `buffer`) + "Buffer" | "buffer" => Buffer, + // console WIRE_JS + "console" => Console, + // encoding WIRE_JS + "TextDecoder" | "TextEncoder" | "TextDecoderStream" | "TextEncoderStream" => Encoding, + // events WIRE_JS + "Event" | "EventTarget" | "CustomEvent" => Events, + // gc WIRE_JS + "gc" => Gc, + // http (NodeFetch) WIRE_JS — fetch + DOM-ish surface + XHR + FormData/Blob/File globals + "fetch" | "Headers" | "Request" | "Response" => NodeFetch, + "Blob" | "File" | "FormData" | "XMLHttpRequest" => NodeFetch, + // intl WIRE_JS + "Intl" => Intl, + // module WIRE_JS — `globalThis.require = require;` + "require" => Module, + // process WIRE_JS + "process" => Process, + // structured_clone WIRE_JS + "structuredClone" => StructuredClone, + // timeout WIRE_JS (we map to the `Timers` cap; `timers` and `timeout` JS + // modules collapse into one capability for the user's purposes) + "setTimeout" | "setInterval" | "setImmediate" | "clearTimeout" | "clearInterval" + | "clearImmediate" => Timers, + // url WIRE_JS + "URL" | "URLSearchParams" => Url, + // web_crypto WIRE_JS — only `crypto` is wired; Crypto/SubtleCrypto/CryptoKey + // are class names that appear in user code referring to types but are not + // installed as globals by skeleton. Including them would over-detect, so + // we don't. + "crypto" => WebCrypto, + // webstreams WIRE_JS + "ByteLengthQueuingStrategy" + | "CountQueuingStrategy" + | "ReadableByteStreamController" + | "ReadableStream" + | "ReadableStreamBYOBReader" + | "ReadableStreamBYOBRequest" + | "ReadableStreamDefaultController" + | "ReadableStreamDefaultReader" + | "TransformStream" + | "TransformStreamDefaultController" + | "WritableStream" + | "WritableStreamDefaultController" + | "WritableStreamDefaultWriter" => Webstreams, + // websocket WIRE_JS (golem) + "WebSocket" | "WebSocketStream" | "MessageEvent" | "CloseEvent" | "ErrorEvent" => Websocket, + // worker_threads WIRE_JS + "MessageChannel" | "MessagePort" => WorkerThreads, + _ => return None, + }) +} + +/// Static dependencies between capabilities, derived from the JS-level imports +/// in `crates/wasm-rquickjs/skeleton/src/builtin/*.js`. If capability X is +/// enabled, every cap returned here for X must also be enabled, otherwise X's +/// JS module will fail to load. +/// +/// The list is intentionally narrow — it only includes edges between *user- +/// visible* capabilities, not edges into internal `__wasm_rquickjs_builtin/internal/*` +/// modules (those don't have caps). +pub fn dependencies(cap: Capability) -> &'static [Capability] { + use Capability::*; + match cap { + AbortController => &[Events], + Assert => &[Fs, Util], + Buffer => &[StringDecoder], + ChildProcess => &[Buffer, Events, Module, Path, Process], + Console => &[Buffer, Util], + Constants => &[WebCrypto, Fs, Os], + Dgram => &[Buffer, Dns, Events, Process], + Dns => &[Net], + Domain => &[Events], + Encoding => &[Webstreams], + FormDataNode => &[NodeFetch], + Https => &[NodeHttp], + Inspector => &[Events], + Net => &[Buffer, Dns, Events, Fs, Path], + NodeFetch => &[AbortController, Buffer], + NodeHttp => &[Buffer, DiagnosticsChannel, Events, Net], + NodeTest => &[Assert], + Process => &[Events], + Querystring => &[Buffer], + Stream => &[Buffer, Process], + StringDecoder => &[Buffer], + Timers => &[AsyncHooks], + Tls => &[Net], + TraceEvents => &[Util], + Tty => &[Net], + Url => &[Querystring], + Util => &[Encoding, WebCrypto], + WebCrypto => &[AbortController, Buffer], + Zlib => &[Buffer, Stream], + // Caps with no inter-cap dependencies (only internal/native deps). + AsyncHooks | Base64 | Cluster | DiagnosticsChannel | Events | Fs | Gc | Http2 | Intl + | Module | Os | Path | PerfHooks | Punycode | Readline | Repl | Sqlite + | StructuredClone | V8 | Vm | Websocket | Webstreams | WorkerThreads => &[], + } +} + +/// Compute the transitive closure of capabilities under [`dependencies`]. +pub fn closure(seed: &BTreeSet) -> BTreeSet { + let mut out = seed.clone(); + let mut frontier: Vec = seed.iter().copied().collect(); + while let Some(c) = frontier.pop() { + for &d in dependencies(c) { + if out.insert(d) { + frontier.push(d); + } + } + } + out +} + +// ------------------------------------------------------------------------------------------------- +// Policy: turn the raw scan result + user CLI flags into the final enabled set +// ------------------------------------------------------------------------------------------------- + +/// User-supplied policy applied on top of a raw scan result. +#[derive(Debug, Clone, Default)] +pub struct Policy { + /// Force-include these capabilities regardless of what the scan found. + pub include: BTreeSet, + /// Force-exclude these capabilities. An exclude that conflicts with a + /// transitively-required capability is honored only insofar as it's not + /// re-added by the closure step; conflicts surface in [`PolicyOutcome::ineffective_excludes`]. + pub exclude: BTreeSet, + /// When `true` and the scan flagged dynamic patterns, trim aggressively + /// anyway. When `false` (the default), dynamic patterns force "enable + /// every known capability" as the safety net. + pub trim_unknown: bool, +} + +#[derive(Debug, Clone)] +pub struct PolicyOutcome { + /// Final set of capabilities to enable in the wasm. + pub enabled: BTreeSet, + /// `true` if the conservative fallback (enable all) was triggered by + /// `has_dynamic` and the absence of `trim_unknown`. + pub conservative_fallback: bool, + /// Excludes that survived even though the user asked to remove them + /// (because some other enabled capability still requires them). + pub ineffective_excludes: BTreeSet, +} + +/// Apply the policy: `(used or all) ∪ include - exclude` then closure, then report. +pub fn apply_policy(scan: &ScanResult, policy: &Policy) -> PolicyOutcome { + let conservative = scan.has_dynamic && !policy.trim_unknown; + let base: BTreeSet = if conservative { + ALL_CAPABILITIES.iter().copied().collect() + } else { + scan.used.clone() + }; + let merged: BTreeSet = base.union(&policy.include).copied().collect(); + let after_exclude: BTreeSet = merged.difference(&policy.exclude).copied().collect(); + let enabled = closure(&after_exclude); + let ineffective: BTreeSet = policy + .exclude + .iter() + .copied() + .filter(|c| enabled.contains(c)) + .collect(); + PolicyOutcome { + enabled, + conservative_fallback: conservative, + ineffective_excludes: ineffective, + } +} + +// ------------------------------------------------------------------------------------------------- +// AST visitor +// ------------------------------------------------------------------------------------------------- + +struct Scanner<'src> { + source: &'src str, + out: ScanResult, +} + +impl<'src> Scanner<'src> { + fn new(source: &'src str) -> Self { + Self { + source, + out: ScanResult::default(), + } + } + + fn into_result(self) -> ScanResult { + self.out + } + + fn record_specifier(&mut self, raw: &str, span: Span) { + if raw.starts_with('.') || raw.starts_with('/') { + self.warn(WarningKind::RelativeImport(raw.to_string()), span); + return; + } + // `spec_to_cap` is checked first so `node:fs/promises` resolves to Fs + // before the WIT-style check would mistakenly catch it on the `/`. + if let Some(cap) = spec_to_cap(raw) { + self.out.used.insert(cap); + return; + } + if is_wit_style_specifier(raw) { + self.out.wit_specifiers.insert(raw.to_string()); + return; + } + self.out.unknown_specifiers.insert(raw.to_string()); + } + + fn record_global(&mut self, name: &str) { + if let Some(cap) = global_to_cap(name) { + self.out.used.insert(cap); + } + } + + fn warn(&mut self, kind: WarningKind, span: Span) { + let (line, column) = self.line_column(span); + self.out.warnings.push(Warning { + kind, + source: self.snippet(span), + line, + column, + }); + } + + fn snippet(&self, span: Span) -> String { + let start = span.start as usize; + let end = (span.end as usize).min(self.source.len()); + let mut s = self.source.get(start..end).unwrap_or("").trim().to_string(); + if s.len() > 80 { + s.truncate(77); + s.push_str("..."); + } + s + } + + fn line_column(&self, span: Span) -> (u32, u32) { + let upto = self.source.get(..span.start as usize).unwrap_or(""); + let line = (upto.bytes().filter(|b| *b == b'\n').count() + 1) as u32; + let column = (upto.rsplit('\n').next().map(|l| l.len()).unwrap_or(0) + 1) as u32; + (line, column) + } +} + +impl<'a> Visit<'a> for Scanner<'_> { + // Top-level `import x from 'spec'` and re-exports `export … from 'spec'`. + fn visit_import_declaration(&mut self, decl: &ImportDeclaration<'a>) { + self.record_specifier(decl.source.value.as_str(), decl.source.span); + walk::walk_import_declaration(self, decl); + } + fn visit_export_named_declaration(&mut self, decl: &ExportNamedDeclaration<'a>) { + if let Some(src) = &decl.source { + self.record_specifier(src.value.as_str(), src.span); + } + walk::walk_export_named_declaration(self, decl); + } + fn visit_export_all_declaration(&mut self, decl: &ExportAllDeclaration<'a>) { + self.record_specifier(decl.source.value.as_str(), decl.source.span); + walk::walk_export_all_declaration(self, decl); + } + + // Dynamic `import()`. + fn visit_import_expression(&mut self, expr: &ImportExpression<'a>) { + match &expr.source { + Expression::StringLiteral(s) => self.record_specifier(s.value.as_str(), s.span), + _ => { + self.has_dynamic_set(); + self.warn(WarningKind::DynamicImport, expr.span); + } + } + walk::walk_import_expression(self, expr); + } + + // `require('x')`, `eval(...)`, and `vm.*` — all surface as call expressions. + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + match &call.callee { + // Direct call: identifier + Expression::Identifier(id) => match id.name.as_str() { + "require" => match call.arguments.first() { + Some(Argument::StringLiteral(s)) => { + self.record_specifier(s.value.as_str(), s.span) + } + Some(_) => { + self.has_dynamic_set(); + self.warn(WarningKind::DynamicRequire, call.span); + } + None => {} + }, + "eval" => { + self.has_dynamic_set(); + self.warn(WarningKind::Eval, call.span); + } + _ => {} + }, + // Member call: `vm.runInThisContext(...)`, `vm.compileFunction(...)`, etc. + Expression::StaticMemberExpression(m) => { + if let Expression::Identifier(obj) = &m.object + && obj.name == "vm" + && matches!( + m.property.name.as_str(), + "runInThisContext" | "runInNewContext" | "runInContext" | "compileFunction" + ) + { + self.out.used.insert(Capability::Vm); + self.has_dynamic_set(); + self.warn(WarningKind::VmEval, call.span); + } + } + _ => {} + } + walk::walk_call_expression(self, call); + } + + // `new Function(...)`. + fn visit_new_expression(&mut self, new: &NewExpression<'a>) { + if let Expression::Identifier(id) = &new.callee + && id.name == "Function" + { + self.has_dynamic_set(); + self.warn(WarningKind::NewFunction, new.span); + } + walk::walk_new_expression(self, new); + } + + // Identifier reads → check global table. + fn visit_identifier_reference(&mut self, id: &IdentifierReference<'a>) { + self.record_global(id.name.as_str()); + } +} + +impl Scanner<'_> { + fn has_dynamic_set(&mut self) { + self.out.has_dynamic = true; + } +} + +// ------------------------------------------------------------------------------------------------- +// Tests +// ------------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use camino::Utf8PathBuf; + + fn scan(src: &str) -> ScanResult { + scan_module(Utf8PathBuf::from("test.mjs").as_ref(), src) + } + + #[test] + fn esm_import_node_fs() { + let r = scan(r#"import { readFileSync } from "node:fs";"#); + assert!(r.used.contains(&Capability::Fs)); + assert!(!r.has_dynamic); + assert!(r.warnings.is_empty()); + } + + #[test] + fn bare_specifier_alias() { + let r = scan(r#"import fs from "fs";"#); + assert!(r.used.contains(&Capability::Fs)); + } + + #[test] + fn fs_promises_subpath() { + let r = scan(r#"import * as p from "node:fs/promises";"#); + assert!(r.used.contains(&Capability::Fs)); + } + + #[test] + fn cjs_require_literal() { + let r = scan(r#"const path = require("path");"#); + assert!(r.used.contains(&Capability::Path)); + assert!(!r.has_dynamic); + } + + #[test] + fn cjs_require_dynamic_warns() { + let r = scan( + r#" + const name = "fs"; + const x = require(name); + "#, + ); + assert!(r.has_dynamic); + assert!( + r.warnings + .iter() + .any(|w| matches!(w.kind, WarningKind::DynamicRequire)) + ); + } + + #[test] + fn dynamic_import_literal_is_resolved() { + let r = scan(r#"const m = await import("node:os");"#); + assert!(r.used.contains(&Capability::Os)); + assert!(!r.has_dynamic); + } + + #[test] + fn dynamic_import_expression_warns() { + let r = scan(r#"const m = await import(name);"#); + assert!(r.has_dynamic); + assert!( + r.warnings + .iter() + .any(|w| matches!(w.kind, WarningKind::DynamicImport)) + ); + } + + #[test] + fn eval_warns() { + let r = scan(r#"eval("1+1");"#); + assert!(r.has_dynamic); + assert!( + r.warnings + .iter() + .any(|w| matches!(w.kind, WarningKind::Eval)) + ); + } + + #[test] + fn new_function_warns() { + let r = scan(r#"const f = new Function("return 1");"#); + assert!(r.has_dynamic); + assert!( + r.warnings + .iter() + .any(|w| matches!(w.kind, WarningKind::NewFunction)) + ); + } + + #[test] + fn vm_runintthiscontext_warns_and_sets_vm_cap() { + let r = scan( + r#" + import vm from "node:vm"; + vm.runInThisContext("1+1"); + "#, + ); + assert!(r.used.contains(&Capability::Vm)); + assert!(r.has_dynamic); + assert!( + r.warnings + .iter() + .any(|w| matches!(w.kind, WarningKind::VmEval)) + ); + } + + #[test] + fn global_buffer() { + let r = scan(r#"const b = Buffer.from("hi");"#); + assert!(r.used.contains(&Capability::Buffer)); + } + + #[test] + fn global_fetch() { + let r = scan(r#"const r = await fetch("https://example.com");"#); + assert!(r.used.contains(&Capability::NodeFetch)); + } + + #[test] + fn global_crypto() { + let r = scan(r#"const id = crypto.randomUUID();"#); + assert!(r.used.contains(&Capability::WebCrypto)); + } + + #[test] + fn global_text_encoder() { + let r = scan(r#"const enc = new TextEncoder();"#); + assert!(r.used.contains(&Capability::Encoding)); + } + + #[test] + fn global_set_timeout() { + let r = scan(r#"setTimeout(() => {}, 100);"#); + assert!(r.used.contains(&Capability::Timers)); + } + + #[test] + fn unknown_specifier_recorded() { + let r = scan(r#"import x from "lodash";"#); + assert!(r.unknown_specifiers.contains("lodash")); + assert!(r.used.is_empty()); + } + + #[test] + fn relative_import_warns() { + let r = scan(r#"import x from "./helper.js";"#); + assert!( + r.warnings + .iter() + .any(|w| matches!(&w.kind, WarningKind::RelativeImport(p) if p == "./helper.js")) + ); + } + + #[test] + fn export_from_specifier() { + let r = scan(r#"export { foo } from "node:url";"#); + assert!(r.used.contains(&Capability::Url)); + } + + #[test] + fn export_all_specifier() { + let r = scan(r#"export * from "node:events";"#); + assert!(r.used.contains(&Capability::Events)); + } + + #[test] + fn nested_require_in_block() { + let r = scan( + r#" + function getFs() { + if (true) { + return require("node:fs"); + } + } + "#, + ); + assert!(r.used.contains(&Capability::Fs)); + } + + #[test] + fn wit_specifier_recorded() { + let r = scan(r#"import * as r from "wasi:random/random@0.2.3";"#); + assert!(r.wit_specifiers.contains("wasi:random/random@0.2.3")); + assert!(r.unknown_specifiers.is_empty()); + assert!(r.used.is_empty()); + } + + #[test] + fn node_subpath_not_misclassified_as_wit() { + let r = scan(r#"import * as p from "node:fs/promises";"#); + assert!(r.used.contains(&Capability::Fs)); + assert!(r.wit_specifiers.is_empty()); + } + + #[test] + fn relative_import_carries_location() { + let r = scan("\n\nimport x from \"./helper.js\";"); + let w = r + .warnings + .iter() + .find(|w| matches!(w.kind, WarningKind::RelativeImport(_))) + .expect("relative import warning"); + assert_eq!(w.line, 3); + assert!(w.column > 0); + } + + #[test] + fn url_scheme_not_misclassified_as_wit() { + // data: and http: shouldn't be treated as WIT-style; they go to unknown. + let r = scan(r#"const x = await import("data:text/javascript,export default 1");"#); + assert!(r.unknown_specifiers.iter().any(|s| s.starts_with("data:"))); + assert!(r.wit_specifiers.is_empty()); + } + + #[test] + fn http_alias_specifiers() { + let r = scan(r#"import { Server } from "node:http";"#); + assert!(r.used.contains(&Capability::NodeHttp)); + let r2 = scan(r#"import { Agent } from "node:_http_agent";"#); + assert!(r2.used.contains(&Capability::NodeHttp)); + } + + // ----- audited globals ----- + + #[test] + fn global_form_data_maps_to_node_fetch() { + // `globalThis.FormData` is wired by http (NodeFetch), not formdata_node. + let r = scan(r#"const f = new FormData();"#); + assert!(r.used.contains(&Capability::NodeFetch)); + assert!(!r.used.contains(&Capability::FormDataNode)); + } + + #[test] + fn global_event_target_maps_to_events() { + let r = scan(r#"class X extends EventTarget {}"#); + assert!(r.used.contains(&Capability::Events)); + } + + #[test] + fn global_message_channel_maps_to_worker_threads() { + let r = scan(r#"const ch = new MessageChannel();"#); + assert!(r.used.contains(&Capability::WorkerThreads)); + } + + #[test] + fn global_dom_exception_maps_to_abort_controller() { + let r = scan(r#"throw new DOMException("x");"#); + assert!(r.used.contains(&Capability::AbortController)); + } + + #[test] + fn global_require_maps_to_module() { + let r = scan(r#"const x = require;"#); // bare reference, not a call + assert!(r.used.contains(&Capability::Module)); + } + + #[test] + fn lowercase_buffer_global() { + let r = scan(r#"const x = buffer.kMaxLength;"#); + assert!(r.used.contains(&Capability::Buffer)); + } + + #[test] + fn queue_microtask_is_not_a_capability() { + // queueMicrotask is a QuickJS-native global; we shouldn't pull in Timers for it. + let r = scan(r#"queueMicrotask(() => 1);"#); + assert!(!r.used.contains(&Capability::Timers)); + } + + // ----- dependencies + closure ----- + + #[test] + fn closure_pulls_node_fetch_deps() { + let mut seed = BTreeSet::new(); + seed.insert(Capability::NodeFetch); + let c = closure(&seed); + assert!(c.contains(&Capability::AbortController)); + assert!(c.contains(&Capability::Events)); // from AbortController + assert!(c.contains(&Capability::Buffer)); + assert!(c.contains(&Capability::StringDecoder)); // from Buffer + } + + #[test] + fn closure_node_http_pulls_net() { + let mut seed = BTreeSet::new(); + seed.insert(Capability::NodeHttp); + let c = closure(&seed); + assert!(c.contains(&Capability::Net)); + assert!(c.contains(&Capability::Dns)); + } + + #[test] + fn closure_idempotent() { + let mut seed = BTreeSet::new(); + seed.insert(Capability::Url); + let c1 = closure(&seed); + let c2 = closure(&c1); + assert_eq!(c1, c2); + } + + #[test] + fn marker_name_roundtrip() { + for &c in ALL_CAPABILITIES { + assert_eq!(Capability::from_marker_name(c.marker_name()), Some(c)); + } + } + + // ----- policy ----- + + #[test] + fn policy_no_dynamic_uses_scan_set() { + let mut scan = ScanResult::default(); + scan.used.insert(Capability::Fs); + let outcome = apply_policy(&scan, &Policy::default()); + assert!(outcome.enabled.contains(&Capability::Fs)); + assert!(!outcome.conservative_fallback); + assert!(!outcome.enabled.contains(&Capability::WebCrypto)); + } + + #[test] + fn policy_dynamic_falls_back_to_all() { + let mut scan = ScanResult::default(); + scan.has_dynamic = true; + let outcome = apply_policy(&scan, &Policy::default()); + assert!(outcome.conservative_fallback); + assert!(outcome.enabled.len() >= ALL_CAPABILITIES.len()); + } + + #[test] + fn policy_trim_unknown_overrides_dynamic_fallback() { + let mut scan = ScanResult::default(); + scan.has_dynamic = true; + scan.used.insert(Capability::Fs); + let mut p = Policy::default(); + p.trim_unknown = true; + let outcome = apply_policy(&scan, &p); + assert!(!outcome.conservative_fallback); + assert!(outcome.enabled.contains(&Capability::Fs)); + // Should not pull in WebCrypto unless something requires it. + assert!(!outcome.enabled.contains(&Capability::WebCrypto)); + } + + #[test] + fn policy_include_adds_capability() { + let scan = ScanResult::default(); + let mut p = Policy::default(); + p.include.insert(Capability::Sqlite); + let outcome = apply_policy(&scan, &p); + assert!(outcome.enabled.contains(&Capability::Sqlite)); + } + + #[test] + fn policy_exclude_removes_when_safe() { + let mut scan = ScanResult::default(); + scan.used.insert(Capability::Fs); + scan.used.insert(Capability::Sqlite); + let mut p = Policy::default(); + p.exclude.insert(Capability::Sqlite); + let outcome = apply_policy(&scan, &p); + assert!(!outcome.enabled.contains(&Capability::Sqlite)); + assert!(outcome.ineffective_excludes.is_empty()); + } + + #[test] + fn policy_exclude_re_added_via_closure_is_ineffective() { + // User asks to exclude Buffer, but they also use NodeFetch which transitively + // requires Buffer → exclude is reported as ineffective. + let mut scan = ScanResult::default(); + scan.used.insert(Capability::NodeFetch); + let mut p = Policy::default(); + p.exclude.insert(Capability::Buffer); + let outcome = apply_policy(&scan, &p); + assert!(outcome.enabled.contains(&Capability::Buffer)); + assert!(outcome.ineffective_excludes.contains(&Capability::Buffer)); + } + + // ----- transitive resolution via scan_entry_point ----- + + #[test] + fn entry_point_follows_relative_import() { + let dir = camino_tempfile_workaround(); + let helper = dir.join("helper.js"); + std::fs::write(&helper, "import 'node:fs';\nexport const x = 1;").unwrap(); + let entry = dir.join("entry.js"); + std::fs::write(&entry, "import './helper.js';\n").unwrap(); + let r = scan_entry_point(&entry); + assert!(r.used.contains(&Capability::Fs)); + assert!(r.warnings.is_empty(), "warnings: {:?}", r.warnings); + } + + #[test] + fn entry_point_resolves_extensionless() { + let dir = camino_tempfile_workaround(); + let helper = dir.join("util.js"); + std::fs::write(&helper, "import 'node:os';").unwrap(); + let entry = dir.join("a.js"); + std::fs::write(&entry, "import './util';\n").unwrap(); + let r = scan_entry_point(&entry); + assert!(r.used.contains(&Capability::Os)); + } + + #[test] + fn entry_point_resolves_index_js() { + let dir = camino_tempfile_workaround(); + let sub = dir.join("lib"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(sub.join("index.js"), "import 'node:path';").unwrap(); + let entry = dir.join("a.js"); + std::fs::write(&entry, "import './lib';\n").unwrap(); + let r = scan_entry_point(&entry); + assert!(r.used.contains(&Capability::Path)); + } + + #[test] + fn entry_point_handles_cycles() { + let dir = camino_tempfile_workaround(); + std::fs::write(dir.join("a.js"), "import './b.js';\nimport 'node:fs';").unwrap(); + std::fs::write(dir.join("b.js"), "import './a.js';\nimport 'node:os';").unwrap(); + let r = scan_entry_point(&dir.join("a.js")); + assert!(r.used.contains(&Capability::Fs)); + assert!(r.used.contains(&Capability::Os)); + } + + #[test] + fn entry_point_unresolvable_yields_warning() { + let dir = camino_tempfile_workaround(); + let entry = dir.join("a.js"); + std::fs::write(&entry, "import './does-not-exist.js';").unwrap(); + let r = scan_entry_point(&entry); + assert!( + r.warnings + .iter() + .any(|w| matches!(&w.kind, WarningKind::UnresolvableImport(s) if s.contains("does-not-exist"))) + ); + } + + /// Make a unique temp directory for a single test. Avoids needing the + /// camino-tempfile crate in normal deps; uses std::env::temp_dir. + fn camino_tempfile_workaround() -> Utf8PathBuf { + use std::sync::atomic::{AtomicU32, Ordering}; + static N: AtomicU32 = AtomicU32::new(0); + let id = N.fetch_add(1, Ordering::Relaxed); + let pid = std::process::id(); + let mut p = Utf8PathBuf::from_path_buf(std::env::temp_dir()).unwrap(); + p.push(format!("wasm-rquickjs-cap-scan-{pid}-{id}")); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).unwrap(); + p + } +} diff --git a/crates/wasm-rquickjs/src/exports.rs b/crates/wasm-rquickjs/src/exports.rs index d256d558a..2faf61ca5 100644 --- a/crates/wasm-rquickjs/src/exports.rs +++ b/crates/wasm-rquickjs/src/exports.rs @@ -79,7 +79,10 @@ pub fn generate_export_impls( mod builtin; } } else { - quote! { mod builtin; } + quote! { + mod builtin; + mod capabilities; + } }; let lib_tokens = quote! { diff --git a/crates/wasm-rquickjs/src/inject.rs b/crates/wasm-rquickjs/src/inject.rs index 2e791d953..47ca3fdc8 100644 --- a/crates/wasm-rquickjs/src/inject.rs +++ b/crates/wasm-rquickjs/src/inject.rs @@ -332,6 +332,123 @@ fn patch_js_offsets_in_output(output: &mut [u8], offsets: &[(u32, u32)]) -> anyh Ok(()) } +// --------------------------------------------------------------------------- +// Capability gates patching +// --------------------------------------------------------------------------- + +/// Magic prefix of the capability-gates slot embedded by the skeleton. +pub const CAPABILITY_GATES_MAGIC: &[u8; 16] = b"WASM_RQJS_CAPS\x01\x00"; + +/// Magic suffix of the capability-gates slot, used to validate the slot's +/// integrity before patching. +pub const CAPABILITY_GATES_END_MAGIC: &[u8; 16] = b"WASM_RQJS_CAPSND"; + +/// Total size of the capability-gates slot: MAGIC(16) + GATES(8) + END_MAGIC(16). +const CAPABILITY_GATES_SLOT_SIZE: usize = 40; + +/// Locate every capability-gates slot in the raw bytes of `wasm`. Returns the +/// offsets of the magic prefixes, in the order they appear. +/// +/// The slot is embedded by the skeleton via `#[link_section]` and ends up +/// inside an active data segment, so its 16-byte magic is preserved verbatim +/// in the wasm binary. Searching for the magic + end-magic pair is sufficient +/// to locate it without parsing the module structure. +/// +/// A wasm component embedding the skeleton may contain the slot more than once +/// (e.g. the same module appearing in multiple core modules of a component, or +/// post-wizer snapshot data). All copies must be patched together so they +/// agree on the same gate values. +fn find_capability_gates_slots(wasm: &[u8]) -> Vec { + let mut out = Vec::new(); + if wasm.len() < CAPABILITY_GATES_SLOT_SIZE { + return out; + } + let limit = wasm.len() - CAPABILITY_GATES_SLOT_SIZE; + let mut i = 0; + while i <= limit { + if &wasm[i..i + 16] == CAPABILITY_GATES_MAGIC + && &wasm[i + 24..i + CAPABILITY_GATES_SLOT_SIZE] == CAPABILITY_GATES_END_MAGIC + { + out.push(i); + // Slots cannot overlap; skip past the matched region. + i += CAPABILITY_GATES_SLOT_SIZE; + } else { + i += 1; + } + } + out +} + +/// Patch every capability-gates bitset embedded in `wasm` to the given value. +/// +/// Bit `i` corresponds to the skeleton's `Capability` variant with discriminant +/// `i`. Setting a bit enables that capability; clearing it disables the +/// capability so the skeleton skips its module registration and global wiring. +/// Combined with a downstream wasm-level dead-code elimination pass, disabled +/// capabilities also drop their host imports (`wasi:filesystem`, `wasi:sockets`, +/// etc.). +/// +/// Returns an error if the capability-gates marker is not present in `wasm`, +/// which generally means the skeleton was built without the capability-gates +/// support or the slot has been stripped already. +pub fn patch_capability_gates_in_bytes( + wasm: &[u8], + enabled_bits: u64, +) -> anyhow::Result> { + let positions = find_capability_gates_slots(wasm); + if positions.is_empty() { + return Err(anyhow!( + "Capability-gates marker not found in WASM. The component does not appear \ + to support per-capability trimming, or the marker has been stripped." + )); + } + + let mut out = wasm.to_vec(); + let bytes = enabled_bits.to_le_bytes(); + for pos in positions { + out[pos + 16..pos + 24].copy_from_slice(&bytes); + } + Ok(out) +} + +/// Read the current capability-gates bitset from `wasm`. Useful for round-trip +/// testing and for letting callers see what the slot was patched with last. +/// +/// If multiple slots are present, this returns the value from the first one +/// and asserts that they all agree. Disagreement indicates a partial patch +/// (likely a bug) and is reported as an error. +pub fn read_capability_gates_from_bytes(wasm: &[u8]) -> anyhow::Result { + let positions = find_capability_gates_slots(wasm); + let first = *positions + .first() + .ok_or_else(|| anyhow!("Capability-gates marker not found in WASM"))?; + let value = u64::from_le_bytes(wasm[first + 16..first + 24].try_into().unwrap()); + for &pos in &positions[1..] { + let v = u64::from_le_bytes(wasm[pos + 16..pos + 24].try_into().unwrap()); + if v != value { + return Err(anyhow!( + "Capability-gates slots disagree: slot at offset {first} = {value:#x}, \ + slot at offset {pos} = {v:#x}. The wasm has been partially patched." + )); + } + } + Ok(value) +} + +/// File-level convenience wrapper around [`patch_capability_gates_in_bytes`]. +pub fn patch_capability_gates( + input: &Utf8Path, + output: &Utf8Path, + enabled_bits: u64, +) -> anyhow::Result<()> { + let bytes = std::fs::read(input.as_std_path()) + .with_context(|| format!("Failed to read input component: {input}"))?; + let patched = patch_capability_gates_in_bytes(&bytes, enabled_bits)?; + std::fs::write(output.as_std_path(), &patched) + .with_context(|| format!("Failed to write output component: {output}"))?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -387,4 +504,135 @@ mod tests { assert_eq!(page_align(WASM_PAGE_SIZE), WASM_PAGE_SIZE); assert_eq!(page_align(WASM_PAGE_SIZE + 1), 2 * WASM_PAGE_SIZE); } + + /// Build a synthetic 40-byte capability-gates slot with the given bitset + /// to mirror the layout produced by the skeleton's static initializer. + fn build_test_gates_slot(gates: u64) -> Vec { + let mut v = Vec::with_capacity(CAPABILITY_GATES_SLOT_SIZE); + v.extend_from_slice(CAPABILITY_GATES_MAGIC); + v.extend_from_slice(&gates.to_le_bytes()); + v.extend_from_slice(CAPABILITY_GATES_END_MAGIC); + v + } + + #[test] + fn test_capability_gates_magic_lengths() { + assert_eq!(CAPABILITY_GATES_MAGIC.len(), 16); + assert_eq!(CAPABILITY_GATES_END_MAGIC.len(), 16); + assert_ne!(CAPABILITY_GATES_MAGIC, CAPABILITY_GATES_END_MAGIC); + } + + #[test] + fn test_find_capability_gates_slot_at_zero() { + let slot = build_test_gates_slot(u64::MAX); + assert_eq!(find_capability_gates_slots(&slot), vec![0]); + } + + #[test] + fn test_find_capability_gates_slot_embedded() { + let mut wasm = vec![0xAA; 1234]; + let slot = build_test_gates_slot(0x1234_5678_9abc_def0); + wasm.extend_from_slice(&slot); + wasm.extend_from_slice(&[0xBB; 100]); + assert_eq!(find_capability_gates_slots(&wasm), vec![1234]); + } + + #[test] + fn test_find_capability_gates_slot_missing() { + let buf = vec![0u8; 1024]; + assert!(find_capability_gates_slots(&buf).is_empty()); + // Too small to contain a slot at all + assert!(find_capability_gates_slots(&[0u8; 10]).is_empty()); + } + + #[test] + fn test_find_and_patch_multiple_capability_gates_slots() { + // Components can embed the skeleton's data more than once. Make sure we + // find each occurrence and that patching updates them all coherently. + let mut wasm = vec![0u8; 0]; + wasm.extend_from_slice(&build_test_gates_slot(u64::MAX)); + wasm.extend_from_slice(&[0u8; 200]); + wasm.extend_from_slice(&build_test_gates_slot(u64::MAX)); + wasm.extend_from_slice(&[0u8; 50]); + wasm.extend_from_slice(&build_test_gates_slot(u64::MAX)); + + let positions = find_capability_gates_slots(&wasm); + assert_eq!(positions.len(), 3); + + let new_bits: u64 = 0x0123_4567_89ab_cdef; + let patched = patch_capability_gates_in_bytes(&wasm, new_bits).unwrap(); + + // All slots should now hold the same patched value, so reading reports it. + assert_eq!(read_capability_gates_from_bytes(&patched).unwrap(), new_bits); + + // Sanity: each individual slot in the patched buffer carries the new value. + let bytes = new_bits.to_le_bytes(); + for pos in find_capability_gates_slots(&patched) { + assert_eq!(&patched[pos + 16..pos + 24], &bytes); + } + } + + #[test] + fn test_patch_and_read_capability_gates_roundtrip() { + let mut wasm = vec![0xCD; 200]; + wasm.extend_from_slice(&build_test_gates_slot(u64::MAX)); + wasm.extend_from_slice(&[0xEF; 200]); + + // Default reads as all-enabled. + assert_eq!(read_capability_gates_from_bytes(&wasm).unwrap(), u64::MAX); + + // Patch to a specific bitset and confirm. + let new_bits: u64 = 0xCAFE_BABE_DEAD_BEEF; + let patched = patch_capability_gates_in_bytes(&wasm, new_bits).unwrap(); + assert_eq!(read_capability_gates_from_bytes(&patched).unwrap(), new_bits); + + // Surrounding bytes must be untouched. + assert_eq!(&patched[..200], &wasm[..200]); + assert_eq!(&patched[200 + CAPABILITY_GATES_SLOT_SIZE..], &wasm[200 + CAPABILITY_GATES_SLOT_SIZE..]); + + // Magic markers must be preserved across the patch. + assert_eq!(&patched[200..200 + 16], CAPABILITY_GATES_MAGIC.as_slice()); + assert_eq!( + &patched[200 + 24..200 + CAPABILITY_GATES_SLOT_SIZE], + CAPABILITY_GATES_END_MAGIC.as_slice() + ); + } + + #[test] + fn test_patch_capability_gates_no_marker() { + let buf = vec![0u8; 100]; + let result = patch_capability_gates_in_bytes(&buf, 0); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Capability-gates marker not found") + ); + } + + #[test] + fn test_capability_enabled_bits_helper() { + use crate::capability_scan::{Capability, enabled_bits}; + + // Empty set ⇒ all-zero bitmask + assert_eq!(enabled_bits(std::iter::empty::()), 0); + + // Single capability sets exactly its bit. + let only_console = enabled_bits([Capability::Console]); + assert_eq!(only_console, 1u64 << Capability::Console.bit_index()); + + // Two unrelated capabilities OR together. + let pair = enabled_bits([Capability::Fs, Capability::Sqlite]); + assert_eq!( + pair, + (1u64 << Capability::Fs.bit_index()) | (1u64 << Capability::Sqlite.bit_index()) + ); + + // The enum is laid out so all 52 bits fit inside the lower half of u64. + let all_known: u64 = enabled_bits(crate::capability_scan::ALL_CAPABILITIES.iter().copied()); + assert_eq!(all_known.count_ones(), 52); + // Bits 52..64 must be unused. + assert_eq!(all_known & !((1u64 << 52) - 1), 0); + } } diff --git a/crates/wasm-rquickjs/src/lib.rs b/crates/wasm-rquickjs/src/lib.rs index a810bae19..0387f1d54 100644 --- a/crates/wasm-rquickjs/src/lib.rs +++ b/crates/wasm-rquickjs/src/lib.rs @@ -63,6 +63,7 @@ impl GenerationTarget { } mod async_values; +pub mod capability_scan; mod conversions; mod exports; mod imports; @@ -76,7 +77,11 @@ mod types; mod typescript; mod wit; -pub use inject::{SLOT_END_MAGIC, SLOT_MAGIC, create_marker_file, inject_js_into_component}; +pub use inject::{ + CAPABILITY_GATES_END_MAGIC, CAPABILITY_GATES_MAGIC, SLOT_END_MAGIC, SLOT_MAGIC, + create_marker_file, inject_js_into_component, patch_capability_gates, + patch_capability_gates_in_bytes, read_capability_gates_from_bytes, +}; #[cfg(feature = "optimize")] pub use optimize::optimize_component; diff --git a/src/cli.rs b/src/cli.rs index f1952072b..e1ab95039 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -95,6 +95,33 @@ pub enum Command { #[arg(long, default_value = "wizer-initialize")] init_func: String, }, + /// Scan a JavaScript module and report which skeleton built-ins it appears to use. + /// This is a research/diagnostic tool for the per-app trimming work. + ScanCapabilities { + /// Path(s) to JavaScript entry-point files to scan. Each file is scanned + /// recursively (relative imports are followed transitively); the union of + /// all results is reported. + #[arg(long, required = true)] + js: Vec, + + /// Force-include a capability by its marker name (e.g. `fs`, `node_http`). + /// Repeatable. + #[arg(long = "include")] + include: Vec, + + /// Force-exclude a capability by its marker name (e.g. `vm`, `sqlite`). + /// Repeatable. Excludes that conflict with a transitively-required + /// capability are reported as ineffective and remain enabled. + #[arg(long = "exclude")] + exclude: Vec, + + /// When set, trim aggressively even if the JS contains dynamic patterns + /// (`require(varName)`, `import(expr)`, `eval`, `new Function`, `vm.run*`). + /// Default behavior is conservative: any dynamic pattern → enable + /// every known capability. + #[arg(long = "trim-unknown", default_value_t = false)] + trim_unknown: bool, + }, /// Inject JavaScript source into a compiled WASM component template InjectJs { /// Path to the template WASM component (compiled with --js-modules name=@slot) @@ -110,6 +137,34 @@ pub enum Command { /// then additional modules in order). #[arg(long, required = true)] js: Vec, + + /// Force-include a capability by its marker name when patching the + /// capability-gates slot (e.g. `fs`, `node_http`). Repeatable. + /// + /// Implies enabling per-capability gate patching: when neither + /// `--include`, `--exclude`, nor `--auto-trim` is set, the gates slot + /// is left untouched and every capability stays enabled. + #[arg(long = "include")] + include: Vec, + + /// Force-exclude a capability by its marker name. Repeatable. + /// Excludes that conflict with a transitively-required capability are + /// reported as ineffective and remain enabled. + #[arg(long = "exclude")] + exclude: Vec, + + /// Scan the JS sources, then patch the capability-gates slot to enable + /// only the capabilities the scanner reports as needed (after + /// dependency closure). `--include` / `--exclude` further refine the + /// scanner's result. + #[arg(long = "auto-trim", default_value_t = false)] + auto_trim: bool, + + /// When set with `--auto-trim`, also trim aggressively even if the JS + /// contains dynamic patterns (`require(varName)`, `import(expr)`, + /// `eval`, `new Function`, `vm.run*`). Default behavior is conservative. + #[arg(long = "trim-unknown", default_value_t = false)] + trim_unknown: bool, }, } diff --git a/src/main.rs b/src/main.rs index 97139a180..c7ac40d79 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,10 +66,145 @@ fn main() { std::process::exit(1); } } + Command::ScanCapabilities { + js: js_paths, + include, + exclude, + trim_unknown, + } => { + use std::collections::BTreeSet; + use wasm_rquickjs::capability_scan::{ + ALL_CAPABILITIES, Capability, Policy, ScanResult, apply_policy, scan_entry_point, + }; + + // Parse --include / --exclude as capability marker names. + let parse_caps = |label: &str, raw: &[String]| -> BTreeSet { + raw.iter() + .map(|name| { + Capability::from_marker_name(name).unwrap_or_else(|| { + eprintln!( + "Unknown capability '{name}' for --{label}.\nKnown capabilities:" + ); + for c in ALL_CAPABILITIES { + eprintln!(" {}", c.marker_name()); + } + std::process::exit(2); + }) + }) + .collect() + }; + + let policy = Policy { + include: parse_caps("include", include), + exclude: parse_caps("exclude", exclude), + trim_unknown: *trim_unknown, + }; + + // Union scans across all entry points. + let mut combined = ScanResult::default(); + for path in js_paths { + let r = scan_entry_point(path.as_path()); + combined.used.extend(r.used); + combined.unknown_specifiers.extend(r.unknown_specifiers); + combined.wit_specifiers.extend(r.wit_specifiers); + for w in r.warnings { + combined.warnings.push(w); + } + combined.has_dynamic |= r.has_dynamic; + } + + println!( + "Capabilities directly used by JS ({}):", + combined.used.len() + ); + for cap in &combined.used { + println!(" - {} ({:?})", cap.marker_name(), cap); + } + + if !combined.wit_specifiers.is_empty() { + println!( + "\nWIT-style component imports ({}):", + combined.wit_specifiers.len() + ); + for s in &combined.wit_specifiers { + println!(" - {s}"); + } + } + + if !combined.unknown_specifiers.is_empty() { + println!( + "\nUnknown bare specifiers ({}):", + combined.unknown_specifiers.len() + ); + for s in &combined.unknown_specifiers { + println!(" - {s}"); + } + } + + if !combined.warnings.is_empty() { + println!("\nWarnings ({}):", combined.warnings.len()); + for w in &combined.warnings { + println!(" [{}:{}] {:?} — {}", w.line, w.column, w.kind, w.source); + } + } + + println!( + "\nDynamic patterns affecting analysis precision: {}", + combined.has_dynamic + ); + + let outcome = apply_policy(&combined, &policy); + let total = ALL_CAPABILITIES.len(); + let kept = outcome.enabled.len(); + + println!( + "\nEnabled after policy ({} of {} = {:.0}%):", + kept, + total, + 100.0 * kept as f64 / total as f64 + ); + for cap in &outcome.enabled { + let direct = combined.used.contains(cap); + let included = policy.include.contains(cap); + let tag = match (direct, included) { + (true, _) => "(used)", + (false, true) => "(forced via --include)", + (false, false) => "(transitive dep)", + }; + println!(" - {} {tag}", cap.marker_name()); + } + + if outcome.conservative_fallback { + println!( + "\nNOTE: dynamic patterns triggered the conservative fallback \ + (enable everything). Pass --trim-unknown to override." + ); + } + + if !outcome.ineffective_excludes.is_empty() { + println!("\nIneffective --exclude flags (re-added by transitive closure):"); + for c in &outcome.ineffective_excludes { + println!(" - {}", c.marker_name()); + } + } + + let trimmed: Vec<_> = ALL_CAPABILITIES + .iter() + .filter(|c| !outcome.enabled.contains(c)) + .collect(); + println!("\nTrimmable capabilities ({} of {}):", trimmed.len(), total); + for c in &trimmed { + println!(" - {}", c.marker_name()); + } + } Command::InjectJs { input, output, js: js_paths, + include, + exclude, + auto_trim, + trim_unknown, } => { let js_sources: Vec = js_paths .iter() @@ -81,7 +216,108 @@ fn main() { }) .collect(); let js_refs: Vec<&str> = js_sources.iter().map(|s| s.as_str()).collect(); - if let Err(err) = wasm_rquickjs::inject_js_into_component(input, output, &js_refs) { + + // Decide whether to patch the capability-gates slot. + let want_patch = *auto_trim || !include.is_empty() || !exclude.is_empty(); + + if want_patch { + use std::collections::BTreeSet; + use wasm_rquickjs::capability_scan::{ + ALL_CAPABILITIES, Capability, Policy, ScanResult, apply_policy, + enabled_bits, scan_entry_point, + }; + + // Helper to parse capability marker names from `--include`/`--exclude` + // with the same error reporting as `scan-capabilities`. + let parse_caps = |label: &str, raw: &[String]| -> BTreeSet { + raw.iter() + .map(|name| { + Capability::from_marker_name(name).unwrap_or_else(|| { + eprintln!( + "Unknown capability '{name}' for --{label}.\nKnown capabilities:" + ); + for c in ALL_CAPABILITIES { + eprintln!(" {}", c.marker_name()); + } + std::process::exit(2); + }) + }) + .collect() + }; + + // Starting set: + // --auto-trim → scan the JS and use the (closed) used set. + // no --auto-trim → start from "everything enabled" so that + // `--exclude X` removes a single capability and `--include` + // is a no-op on the always-on baseline. + let scan = if *auto_trim { + let mut combined = ScanResult::default(); + for path in js_paths { + let s = scan_entry_point(path); + combined.used.extend(s.used); + combined.unknown_specifiers.extend(s.unknown_specifiers); + combined.wit_specifiers.extend(s.wit_specifiers); + combined.warnings.extend(s.warnings); + combined.has_dynamic |= s.has_dynamic; + } + combined + } else { + let mut all_on = ScanResult::default(); + all_on.used.extend(ALL_CAPABILITIES.iter().copied()); + all_on + }; + + let policy = Policy { + include: parse_caps("include", include), + exclude: parse_caps("exclude", exclude), + trim_unknown: *trim_unknown, + }; + let outcome = apply_policy(&scan, &policy); + + if outcome.conservative_fallback { + eprintln!( + "Note: dynamic JS patterns detected; falling back to enabling all \ + capabilities. Pass --trim-unknown to override (use with care)." + ); + } + if !outcome.ineffective_excludes.is_empty() { + eprintln!( + "Note: ignored --exclude entries that are transitive dependencies \ + of an enabled capability:" + ); + for c in &outcome.ineffective_excludes { + eprintln!(" - {}", c.marker_name()); + } + } + + let bits = enabled_bits(outcome.enabled.iter().copied()); + + eprintln!( + "Patching capability gates: enabling {} of {} capabilities", + outcome.enabled.len(), + ALL_CAPABILITIES.len() + ); + + // Stage to the output path, then patch + inject in two steps so + // the user gets a single output even when both happen. + let staging = output.with_extension("wasm.staging"); + if let Err(err) = + wasm_rquickjs::patch_capability_gates(input, &staging, bits) + { + eprintln!("Error patching capability gates: {err:#}"); + std::process::exit(1); + } + if let Err(err) = + wasm_rquickjs::inject_js_into_component(&staging, output, &js_refs) + { + eprintln!("Error injecting JS: {err:#}"); + let _ = std::fs::remove_file(staging.as_std_path()); + std::process::exit(1); + } + let _ = std::fs::remove_file(staging.as_std_path()); + } else if let Err(err) = + wasm_rquickjs::inject_js_into_component(input, output, &js_refs) + { eprintln!("Error injecting JS: {err:#}"); std::process::exit(1); } diff --git a/tests/binary_inject.rs b/tests/binary_inject.rs index 5361ee7ed..5229b75c5 100644 --- a/tests/binary_inject.rs +++ b/tests/binary_inject.rs @@ -10,7 +10,9 @@ use heck::ToSnakeCase; use std::process::Command; use wasm_rquickjs::{ EmbeddingMode, JsModuleSpec, generate_wrapper_crate, inject_js_into_component, + patch_capability_gates_in_bytes, read_capability_gates_from_bytes, }; +use wasm_rquickjs::capability_scan::{ALL_CAPABILITIES, Capability, enabled_bits}; use wasmtime::component::Val; /// Generates a wrapper crate using BinarySlot mode, compiles it, injects JS, @@ -97,6 +99,9 @@ async fn main() { // Test 3: re-inject different JS into the same template test_reinject_different_js().await; + // Test 4: patch the capability-gates slot, then inject + run + test_patch_capability_gates_and_run().await; + eprintln!("\n=== All binary_inject tests passed ==="); } @@ -244,3 +249,120 @@ export async function asyncHello(name) { return `Second async: ${name}`; } other => panic!("Unexpected: {other:?}"), } } + +/// End-to-end smoke test for the per-capability gates slot: +/// +/// 1. Build a BinarySlot template — its embedded gates default to all-ones. +/// 2. Read the gates and assert the default is "all enabled". +/// 3. Patch the slot to disable a few capabilities the example does not use +/// (Sqlite, Vm, Tls, Dns, Http2). The remaining capabilities stay on so +/// that `example1` still has everything it needs to run. +/// 4. Read back the gates from the patched bytes to confirm round-trip. +/// 5. Inject the JS into the patched wasm and call an exported function to +/// confirm the patched component still instantiates and runs. +async fn test_patch_capability_gates_and_run() { + eprintln!("\n--- test_patch_capability_gates_and_run ---"); + + let builder = BinarySlotTestBuilder::new("example1").expect("Failed to build template"); + + // Step 1 + 2: read the default gates from the freshly-built template. + let template_bytes = std::fs::read(builder.wasm_path.as_std_path()) + .expect("Failed to read template wasm"); + let initial = read_capability_gates_from_bytes(&template_bytes) + .expect("Failed to read default capability gates from template"); + assert_eq!( + initial, + u64::MAX, + "freshly-built template should have all-enabled gates", + ); + eprintln!( + " ✓ default gates = {:#018x} (all {} capabilities enabled)", + initial, + initial.count_ones() + ); + + // Step 3: choose a subset to disable. example1 just runs basic JS that + // returns a string and does no networking, sqlite, vm-eval, etc. + // + // NOTE: `node:module` statically imports nearly every other builtin so it + // can route `require('node:X')` calls. That means flipping off any of the + // builtins it imports while keeping `Module` enabled would crash at JS + // wiring time (the static imports in module.js would fail to resolve). + // Disabling `Module` alongside the other leaves keeps the wiring valid; + // example1 never calls `require()`, so dropping the global is safe. + let to_disable = [ + Capability::Module, + Capability::Sqlite, + Capability::Vm, + Capability::Tls, + Capability::Http2, + ]; + let kept: Vec = ALL_CAPABILITIES + .iter() + .copied() + .filter(|c| !to_disable.contains(c)) + .collect(); + let new_bits = enabled_bits(kept.iter().copied()); + + let patched_bytes = patch_capability_gates_in_bytes(&template_bytes, new_bits) + .expect("patch_capability_gates_in_bytes failed"); + + // Step 4: read back and verify each disabled bit is cleared. + let read_back = read_capability_gates_from_bytes(&patched_bytes) + .expect("Failed to read patched gates"); + assert_eq!(read_back, new_bits, "patched gates must round-trip"); + for cap in to_disable { + assert_eq!( + read_back & (1u64 << cap.bit_index()), + 0, + "bit for {} should be cleared", + cap.marker_name(), + ); + } + eprintln!( + " ✓ patched gates = {:#018x} (disabled {} capabilities)", + read_back, + to_disable.len() + ); + + // Persist the patched template so we can drive the standard JS injection + // path against it. + let patched_template_path = Utf8PathBuf::from(format!( + "tmp/{}-binary-inject/{}-gates-patched.wasm", + builder.example_name, builder.example_name + )); + std::fs::write(patched_template_path.as_std_path(), &patched_bytes) + .expect("Failed to write patched template"); + + // Step 5: inject JS into the patched template and run it. + let injected_path = Utf8PathBuf::from(format!( + "tmp/{}-binary-inject/{}-gates-patched-injected.wasm", + builder.example_name, builder.example_name + )); + let js_source = r#" +export const something = 7; +export function hello(name) { return `gates-patched: ${name}`; } +export async function asyncHello(name) { return `gates-patched async: ${name}`; } +"#; + inject_js_into_component(&patched_template_path, &injected_path, &[js_source]) + .expect("inject_js_into_component failed against patched template"); + + let mut instance = TestInstance::new(&injected_path) + .await + .expect("Failed to create instance from patched+injected wasm"); + + let (result, _stdout) = instance + .invoke_and_capture_output(None, "hello", &[Val::String("World".into())]) + .await; + + match result.expect("Function call failed") { + Some(Val::String(s)) => { + assert!( + s.contains("gates-patched"), + "Expected 'gates-patched' in output, got: {s}", + ); + eprintln!(" ✓ patched+injected run returned: {s}"); + } + other => panic!("Unexpected: {other:?}"), + } +} From 7482bb7d2486a12149961a4d88455671aed9027f Mon Sep 17 00:00:00 2001 From: Daniel Vigovszky Date: Sun, 17 May 2026 23:29:05 +0200 Subject: [PATCH 2/3] WIP Co-authored-by: Daniel Vigovszky --- Cargo.lock | 154 +++- Cargo.toml | 2 + capability-detection.md | 166 +++- .../wasm-rquickjs/skeleton/src/builtin/mod.rs | 784 ++++++++++-------- .../skeleton/src/capabilities.rs | 89 +- .../skeleton/src/internal/module_loading.rs | 95 ++- crates/wasm-rquickjs/src/capability_scan.rs | 52 +- crates/wasm-rquickjs/src/inject.rs | 687 ++++++++++++++- src/main.rs | 245 ++++++ tests/common/mod.rs | 288 +++++++ 10 files changed, 2168 insertions(+), 394 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 68a32c46e..a2acf09ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" dependencies = [ - "gimli", + "gimli 0.33.0", ] [[package]] @@ -575,7 +575,7 @@ dependencies = [ "cranelift-control", "cranelift-entity", "cranelift-isle", - "gimli", + "gimli 0.33.0", "hashbrown 0.17.1", "libm", "log", @@ -957,6 +957,12 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5692dd7b5a1978a5aeb0ce83b7655c58ca8efdcb79d21036ea249da95afec2c6" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + [[package]] name = "fastrand" version = "2.4.1" @@ -1172,6 +1178,17 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +dependencies = [ + "fallible-iterator", + "indexmap", + "stable_deref_trait", +] + [[package]] name = "gimli" version = "0.33.0" @@ -3387,6 +3404,24 @@ dependencies = [ "wat", ] +[[package]] +name = "wasm-eliminator" +version = "0.1.0" +dependencies = [ + "anyhow", + "gimli 0.32.3", + "indexmap", + "log", + "wasm-encoder 0.248.0", + "wasm-metadata 0.248.0", + "wasmparser 0.248.0", + "wasmprinter 0.248.0", + "wat", + "wit-component 0.248.0", + "wit-encoder 0.248.0", + "wit-parser 0.248.0", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -3407,6 +3442,16 @@ dependencies = [ "wasmparser 0.247.0", ] +[[package]] +name = "wasm-encoder" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac92cf547bc18d27ecc521015c08c353b4f18b84ab388bb6d1b6b682c620d9b6" +dependencies = [ + "leb128fmt", + "wasmparser 0.248.0", +] + [[package]] name = "wasm-encoder" version = "0.251.0" @@ -3458,6 +3503,25 @@ dependencies = [ "wasmparser 0.247.0", ] +[[package]] +name = "wasm-metadata" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4f85f11dcdabc91e805c03eb84ccc7b7ef2282c6610bb83c7a7c853425850c" +dependencies = [ + "anyhow", + "auditable-serde", + "flate2", + "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "url", + "wasm-encoder 0.248.0", + "wasmparser 0.248.0", +] + [[package]] name = "wasm-metadata" version = "0.251.0" @@ -3497,7 +3561,7 @@ dependencies = [ "wasmtime-wasi-http", "wasmtime-wizer", "wit-bindgen-core 0.57.1", - "wit-encoder", + "wit-encoder 0.247.0", "wit-parser 0.247.0", ] @@ -3533,6 +3597,7 @@ dependencies = [ "tracing-subscriber", "uuid", "wac-graph", + "wasm-eliminator", "wasm-rquickjs", "wasmparser 0.247.0", "wasmtime", @@ -3565,6 +3630,19 @@ dependencies = [ "serde", ] +[[package]] +name = "wasmparser" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indexmap", + "semver", + "serde", +] + [[package]] name = "wasmparser" version = "0.251.0" @@ -3589,6 +3667,17 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmprinter" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b264a5410b008d4d199a92bf536eae703cbd614482fc1ec53831cf19e1c183" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.248.0", +] + [[package]] name = "wasmprinter" version = "0.251.0" @@ -3616,7 +3705,7 @@ dependencies = [ "encoding_rs", "futures", "fxprof-processed-profile", - "gimli", + "gimli 0.33.0", "ittapi", "libc", "log", @@ -3665,7 +3754,7 @@ dependencies = [ "cranelift-bforest", "cranelift-bitset", "cranelift-entity", - "gimli", + "gimli 0.33.0", "hashbrown 0.17.1", "indexmap", "log", @@ -3680,7 +3769,7 @@ dependencies = [ "target-lexicon", "wasm-encoder 0.251.0", "wasmparser 0.251.0", - "wasmprinter", + "wasmprinter 0.251.0", "wasmtime-internal-component-util", "wasmtime-internal-core", ] @@ -3750,7 +3839,7 @@ dependencies = [ "cranelift-entity", "cranelift-frontend", "cranelift-native", - "gimli", + "gimli 0.33.0", "itertools", "log", "object", @@ -4369,6 +4458,25 @@ dependencies = [ "wit-parser 0.244.0", ] +[[package]] +name = "wit-component" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0012379f0ff47e1d44dd312e76cfa42de2589251f093fb105e9de9db90c89221" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.248.0", + "wasm-metadata 0.248.0", + "wasmparser 0.248.0", + "wit-parser 0.248.0", +] + [[package]] name = "wit-component" version = "0.251.0" @@ -4401,6 +4509,19 @@ dependencies = [ "wit-parser 0.247.0", ] +[[package]] +name = "wit-encoder" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9eefea6899862e37c81fecef708fd01b6aa8f5da2b21c4715939c765977fca6" +dependencies = [ + "id-arena", + "pretty_assertions", + "semver", + "serde", + "wit-parser 0.248.0", +] + [[package]] name = "wit-parser" version = "0.244.0" @@ -4438,6 +4559,25 @@ dependencies = [ "wasmparser 0.247.0", ] +[[package]] +name = "wit-parser" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "247ad505da2915a082fe13204c5ba8788425aea1de54f43b284818cf82637856" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.248.0", +] + [[package]] name = "wit-parser" version = "0.251.0" diff --git a/Cargo.toml b/Cargo.toml index 45fd9b975..63e2de385 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,11 +22,13 @@ pkg-fmt = "zip" [dependencies] "wasm-rquickjs" = { path = "crates/wasm-rquickjs", version = "0.0.0", features = ["optimize"] } +wasm-eliminator = { path = "../../oss/wasm-eliminator/crates/wasm-eliminator" } anyhow = { workspace = true } camino = { workspace = true } clap = { version = "4.5.39", features = ["default", "derive"] } tokio = { workspace = true, features = ["rt"] } +wasmparser_encoder = { workspace = true } [dev-dependencies] anyhow = { workspace = true } diff --git a/capability-detection.md b/capability-detection.md index 185a3d0a9..5bda45c0b 100644 --- a/capability-detection.md +++ b/capability-detection.md @@ -1,24 +1,26 @@ # Plan: Per-app builtin trimming for wasm-rquickjs This plan tracks the remaining work for the runtime capability-gates approach. -The wasm-rquickjs side prototype is in place: gates slot in the skeleton, -host-side patching of all gate slot copies, CLI flags on `inject-js`, and an -end-to-end integration test. +The wasm-rquickjs side prototype is in place: direct per-capability helper gates +in the skeleton, host-side lowering of those helpers to immutable wasm globals, +CLI flags on `inject-js`, and an end-to-end integration test. ## 0. Wait for the wasm-level dead-code / import eliminator **Blocker for actually shipping size + import reductions to users.** -- The current scheme only flips a runtime bit. Without DCE on the produced wasm, - disabling a capability does not remove its native code, JS source bytes, or - the WIT imports it transitively pulls in. +- The current scheme lowers each capability gate to an immutable `i32` wasm + global initialized to `0` or `1`. Without DCE on the produced wasm, disabling + a capability skips runtime registration but does not remove its native code, + JS source bytes, or the WIT imports it transitively pulls in. - A separate tool (already in development by the user) is expected to: - perform component-level dead-code elimination, and - drop unused component model imports based on what is reachable. - We need to wait until that tool is usable end-to-end on a patched wasm-rquickjs base image before we start measuring real wins. - Action: once the tool exists, run it on a `inject-js --auto-trim`-patched - artifact and confirm: + artifact and confirm the eliminator folds `global.get $cap_*` branches and + then removes the now-unreachable pieces: - native builtin Rust code for disabled caps is gone, - JS bodies for disabled caps are gone, - WIT imports that are now dead (e.g. `wasi:filesystem` when `Fs` is off) are @@ -131,3 +133,153 @@ Tied to step 0. Once the DCE/import-stripper tool is available: - cold-start time if measurable. Document the numbers in the README (or a dedicated benchmarks doc). + +## 8. Findings: investigation into making WIT imports actually drop (2026-05) + +This section captures the current state of the joint wasm-eliminator + +wasm-rquickjs effort and what is still blocking end-to-end import pruning. It +supersedes the optimistic wording of step 0 above. + +### Status + +- wasm-eliminator (in `../../oss/wasm-eliminator`) is feature-complete for the + bits this scenario needs: + - constant-prop through immutable globals, + - dead-branch elimination on statically-zero `if`/`block` guards, + - set-valued IPCP, deeper abstract operand stack, + - flat-memory-backed `i32.load` folding, + - outer fixed-point over direct-call argument facts, + - constant-return direct-call folding, + - reachability tracking so dead code does not pollute call-edge liveness or + direct-call argument facts, + - encoder scrubbing of dead `Call` / `ReturnCall` / `RefFunc` to + `unreachable`, + - wasmtime-as-a-library test harness validating shrunk binaries. +- All `cargo test -p wasm-eliminator` is green. +- wasm-rquickjs already does the producer-side work: + - capability gates lowered to immutable wasm globals (already shipped), + - custom [`BuiltinNativeLoader`](file:///Users/vigoo/projects/golem/wasm-rquickjs/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs#L743) + that replaces the generic `rquickjs::loader::ModuleLoader`. This removes + every `ModuleLoader::load_func` monomorphization. +- On the no-console fixture + (`tmp/elim-experiment/example1-no-console-auto-trim.wasm`, + `--auto-trim` reports `enabling 0 of 52 capabilities`): + - core module size shrinks: `55,667,883 B -> 13,235,054 B`, + - `declare_def` monomorphizations: **0 survive** (good), + - `load_func` monomorphizations: **0 survive** (good), + - but `Module::eval_fn::` monomorphizations: **21 survive**, + - component imports: **29 -> 29** (no change), + - WIT imports `wasi:logging/logging`, `wasi:filesystem/*`, `wasi:http/*`, + `wasi:sockets/*` are still present after DCE. + +### Root cause + +The 21 surviving `eval_fn` are kept alive by active element segment 0 (the +big rust-lld-generated table). Some live `call_indirect` site has +`IndexSet::Any` on type `(i32, i32) -> i32`, so Phase A type-fallback in +wasm-eliminator keeps every type-2 slot — including all `eval_fn` — alive. +Each per-D `eval_fn` in turn keeps `D::evaluate` reachable, which keeps the +native code and host imports for D's builtin alive. + +The relevant `call_indirect` is inside the QuickJS C runtime: the dispatch +loads `module->func` from a `JSModuleDef` allocated on the QuickJS heap and +calls it indirectly. The function pointer in that field is whatever was passed +to `JS_NewCModule(ctx, name, Some(callback))`. Today rquickjs calls +`JS_NewCModule(..., Some(Module::eval_fn::))` once per ModuleDef type `D`, +so each `D` contributes a distinct callback function pointer. + +### What we tried in wasm-eliminator + +Earlier in this work we tried a broad Phase G "live source set" approach: +narrow `IndexSet::Any` to a small set whenever no live `i32.const` / +`ref.func` / static-data u32 produced any of the missing indices. This was +**unsound** — it broke tests like +`a3_same_type_dynamic_index_keeps_all_matching_slots`, +`b3_parameter_local_falls_back_to_phase_a`, +`f5_unknown_entry_dispatcher_falls_back_safely`, +`f6_two_callers_one_unknown_collapses_to_top` — because params, imported +function returns, imported globals, opaque arithmetic and opaque memory loads +can all supply call_indirect operands not visible in any constant source set. +That approach has been reverted and the soundness tests prove the boundary. + +### What the oracle says + +Asked the wasm-eliminator oracle for a sound, provenance-aware Phase G with a +realistic chance of solving the QuickJS shape. Verdict: + +- The only sound generic direction is **per-call-site tracked-field provenance + over abstract objects, with escape-to-Top**. Effort: **XL**. +- Even with that, the QuickJS dispatch reaches `call_indirect` through + `i32.load` of an opaque heap-derived pointer whose abstract-object provenance + is lost long before the dispatch site. Realistically, eliminator-only + recovery is **unlikely** without producer cooperation (constructor / writer + summaries telling the analysis "this allocator returns a fresh object whose + field `func_off` is initialized from arg N"). +- The pragmatic alternative is **producer-side**: replace the per-D + `Module::eval_fn::` with a single shared trampoline in wasm-rquickjs + (M/L effort). Then there is exactly one `eval_fn` in the function table, the + type-based Phase A fallback only keeps that one alive, and per-D + `declare` / `evaluate` (and their builtin imports) can become dead. + +### Producer-side shared trampoline: what blocks the in-tree implementation + +Intended design: replace each +`Module::declare_def::(ctx, name)` in +`BuiltinNativeLoader::load` with a single +`Module::declare_def::(ctx, name)` whose +`declare` / `evaluate` dispatch by direct call based on the module's name. Net +result: 21 `eval_fn` monomorphizations collapse to 1, and per-D code becomes +direct-call-only (eliminable when the corresponding capability is gated off). + +The unresolved technical issue is identifying the current module from inside +`UnifiedBuiltinModuleDef::evaluate(ctx, exports)` using only public rquickjs +API: + +- `Module<'js, T>`'s `ptr` and `ctx` fields are **private** (not + `pub(crate)`). +- `Module::from_ptr` and `Module::as_ptr` are `pub(crate)`. +- `Declarations<'js>(Module<'js, Declared>)` and + `Exports<'js>(Module<'js, Declared>)` expose **no public accessor** for the + inner module. +- `Module::name(&self)` is public but requires a `&Module`, which + we cannot obtain through public APIs from inside `evaluate`. + +Workarounds evaluated: + +- **`unsafe` transmute of `&Exports` / `&Declarations` to `&Module`, + then call public `Module::name()`.** Works in practice because both wrappers + are single-field tuple structs around `Module`, but `#[repr(Rust)]` layout + is not formally guaranteed. +- **Thread-local set from `BuiltinNativeLoader::load`.** Works for `declare` + (synchronous inside `Module::declare_def`). Does **not** work for + `evaluate`, which QuickJS may call later from arbitrary module-instantiation + contexts. +- **`ctx.script_or_module_name(0)` inside `evaluate`.** rquickjs itself uses + `script_or_module_name(1)` from a JS-callback path. Whether this returns the + correct module name when called from inside a `JS_NewCModule` init callback + has not been verified. +- **Bypass `Module::declare_def` and call `qjs::JS_NewCModule` directly.** + Blocked: cannot construct `Module` from the returned pointer + because `Module::from_ptr` is `pub(crate)`. +- **Const-generic `UnifiedDef`.** Still monomorphizes per `ID` + — does not reduce the number of `eval_fn` functions. + +### Decision needed + +One of: + +- **A**: ship the transmute-based shared trampoline in wasm-rquickjs (single + helper, well-documented, runtime size/align assertion as a guard). Smallest + change, gets the trampoline working. +- **B**: try `ctx.script_or_module_name(0)` first; if it works inside the init + callback, no transmute is needed. +- **C**: upstream a small patch in rquickjs to make `Module::as_ptr` or + `Exports::module()` / `Declarations::module()` public. Cleanest long-term, + requires a release cycle. +- **D**: invest in the XL Phase G provenance work in wasm-eliminator. Oracle + warns this likely will not even solve QuickJS without producer-side + cooperation, so this is high cost for uncertain payoff. + +Recommendation: A or B (B preferred if `script_or_module_name(0)` is +confirmed to work in an init callback), with C as the long-term cleanup. + diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs b/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs index 72c69bd2e..ca32dca04 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs @@ -1,9 +1,166 @@ -use crate::capabilities::{self, Capability}; +use crate::capabilities; use std::fmt::Write; -#[inline] -fn cap(c: Capability) -> bool { - capabilities::is_enabled(c) +macro_rules! capability_enabled { + (Always) => { + true + }; + (AbortController) => { + capabilities::cap_abort_controller() + }; + (Assert) => { + capabilities::cap_assert() + }; + (AsyncHooks) => { + capabilities::cap_async_hooks() + }; + (Base64) => { + capabilities::cap_base64() + }; + (Buffer) => { + capabilities::cap_buffer() + }; + (ChildProcess) => { + capabilities::cap_child_process() + }; + (Cluster) => { + capabilities::cap_cluster() + }; + (Console) => { + capabilities::cap_console() + }; + (Constants) => { + capabilities::cap_constants() + }; + (Dgram) => { + capabilities::cap_dgram() + }; + (DiagnosticsChannel) => { + capabilities::cap_diagnostics_channel() + }; + (Dns) => { + capabilities::cap_dns() + }; + (Domain) => { + capabilities::cap_domain() + }; + (Encoding) => { + capabilities::cap_encoding() + }; + (Events) => { + capabilities::cap_events() + }; + (FormDataNode) => { + capabilities::cap_formdata_node() + }; + (Fs) => { + capabilities::cap_fs() + }; + (Gc) => { + capabilities::cap_gc() + }; + (Http2) => { + capabilities::cap_http2() + }; + (Https) => { + capabilities::cap_https() + }; + (Inspector) => { + capabilities::cap_inspector() + }; + (Intl) => { + capabilities::cap_intl() + }; + (Module) => { + capabilities::cap_module() + }; + (Net) => { + capabilities::cap_net() + }; + (NodeFetch) => { + capabilities::cap_node_fetch() + }; + (NodeHttp) => { + capabilities::cap_node_http() + }; + (NodeTest) => { + capabilities::cap_node_test() + }; + (Os) => { + capabilities::cap_os() + }; + (Path) => { + capabilities::cap_path() + }; + (PerfHooks) => { + capabilities::cap_perf_hooks() + }; + (Process) => { + capabilities::cap_process() + }; + (Punycode) => { + capabilities::cap_punycode() + }; + (Querystring) => { + capabilities::cap_querystring() + }; + (Readline) => { + capabilities::cap_readline() + }; + (Repl) => { + capabilities::cap_repl() + }; + (Sqlite) => { + capabilities::cap_sqlite() + }; + (Stream) => { + capabilities::cap_stream() + }; + (StringDecoder) => { + capabilities::cap_string_decoder() + }; + (StructuredClone) => { + capabilities::cap_structured_clone() + }; + (Timers) => { + capabilities::cap_timers() + }; + (Tls) => { + capabilities::cap_tls() + }; + (TraceEvents) => { + capabilities::cap_trace_events() + }; + (Tty) => { + capabilities::cap_tty() + }; + (Url) => { + capabilities::cap_url() + }; + (Util) => { + capabilities::cap_util() + }; + (V8) => { + capabilities::cap_v8() + }; + (Vm) => { + capabilities::cap_vm() + }; + (WebCrypto) => { + capabilities::cap_web_crypto() + }; + (Websocket) => { + capabilities::cap_websocket() + }; + (Webstreams) => { + capabilities::cap_webstreams() + }; + (WorkerThreads) => { + capabilities::cap_worker_threads() + }; + (Zlib) => { + capabilities::cap_zlib() + }; } mod abort_controller; @@ -136,19 +293,19 @@ pub fn add_module_resolvers( // is "enabled", so this is a no-op shape change unless the host patches the // capability slot in the wasm. See `crate::capabilities`. - let resolver = if cap(Capability::AbortController) { + let resolver = if capability_enabled!(AbortController) { resolver.with_module("__wasm_rquickjs_builtin/abort_controller") } else { resolver }; - let resolver = if cap(Capability::Base64) { + let resolver = if capability_enabled!(Base64) { resolver.with_module("__wasm_rquickjs_builtin/base64_native") } else { resolver }; - let resolver = if cap(Capability::Console) { + let resolver = if capability_enabled!(Console) { resolver .with_module("__wasm_rquickjs_builtin/console_native") .with_module("__wasm_rquickjs_builtin/console") @@ -158,7 +315,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Timers) { + let resolver = if capability_enabled!(Timers) { resolver .with_module("__wasm_rquickjs_builtin/timeout_native") .with_module("__wasm_rquickjs_builtin/timeout") @@ -170,13 +327,13 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Gc) { + let resolver = if capability_enabled!(Gc) { resolver.with_module("__wasm_rquickjs_builtin/gc_native") } else { resolver }; - let resolver = if cap(Capability::NodeFetch) { + let resolver = if capability_enabled!(NodeFetch) { resolver .with_module("__wasm_rquickjs_builtin/http_native") .with_module("__wasm_rquickjs_builtin/http") @@ -186,7 +343,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Webstreams) { + let resolver = if capability_enabled!(Webstreams) { resolver .with_module("__wasm_rquickjs_builtin/streams") .with_module("__wasm_rquickjs_builtin/webstreams_wrapper") @@ -197,7 +354,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Encoding) { + let resolver = if capability_enabled!(Encoding) { resolver .with_module("__wasm_rquickjs_builtin/encoding_native") .with_module("__wasm_rquickjs_builtin/encoding") @@ -205,7 +362,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Intl) { + let resolver = if capability_enabled!(Intl) { resolver .with_module("__wasm_rquickjs_builtin/intl_native") .with_module("__wasm_rquickjs_builtin/intl") @@ -213,7 +370,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Util) { + let resolver = if capability_enabled!(Util) { resolver .with_module("node:util") .with_module("node:util/types") @@ -223,7 +380,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Fs) { + let resolver = if capability_enabled!(Fs) { resolver .with_module("__wasm_rquickjs_builtin/fs_native") .with_module("node:fs") @@ -235,7 +392,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Buffer) { + let resolver = if capability_enabled!(Buffer) { resolver .with_module("node:buffer") .with_module("buffer") @@ -245,7 +402,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Os) { + let resolver = if capability_enabled!(Os) { resolver .with_module("__wasm_rquickjs_builtin/os_native") .with_module("node:os") @@ -254,7 +411,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Assert) { + let resolver = if capability_enabled!(Assert) { resolver .with_module("node:assert") .with_module("assert") @@ -264,7 +421,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Querystring) { + let resolver = if capability_enabled!(Querystring) { resolver .with_module("node:querystring") .with_module("querystring") @@ -272,7 +429,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::ChildProcess) { + let resolver = if capability_enabled!(ChildProcess) { resolver .with_module("node:child_process") .with_module("child_process") @@ -280,19 +437,19 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::NodeTest) { + let resolver = if capability_enabled!(NodeTest) { resolver.with_module("node:test") } else { resolver }; - let resolver = if cap(Capability::Module) { + let resolver = if capability_enabled!(Module) { resolver.with_module("node:module").with_module("module") } else { resolver }; - let resolver = if cap(Capability::Process) { + let resolver = if capability_enabled!(Process) { resolver .with_module("__wasm_rquickjs_builtin/process_native") .with_module("node:process") @@ -301,7 +458,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Path) { + let resolver = if capability_enabled!(Path) { resolver .with_module("node:path") .with_module("path") @@ -313,7 +470,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Punycode) { + let resolver = if capability_enabled!(Punycode) { resolver .with_module("node:punycode") .with_module("punycode") @@ -321,7 +478,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Url) { + let resolver = if capability_enabled!(Url) { resolver .with_module("__wasm_rquickjs_builtin/url_native") .with_module("__wasm_rquickjs_builtin/url") @@ -331,13 +488,13 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Events) { + let resolver = if capability_enabled!(Events) { resolver.with_module("node:events").with_module("events") } else { resolver }; - let resolver = if cap(Capability::Stream) { + let resolver = if capability_enabled!(Stream) { resolver .with_module("node:stream") .with_module("node:stream/promises") @@ -349,13 +506,13 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::FormDataNode) { + let resolver = if capability_enabled!(FormDataNode) { resolver.with_module("formdata-node") } else { resolver }; - let resolver = if cap(Capability::StringDecoder) { + let resolver = if capability_enabled!(StringDecoder) { resolver .with_module("__wasm_rquickjs_builtin/string_decoder_native") .with_module("node:string_decoder") @@ -364,7 +521,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::WebCrypto) { + let resolver = if capability_enabled!(WebCrypto) { resolver .with_module("__wasm_rquickjs_builtin/web_crypto_native") .with_module("__wasm_rquickjs_builtin/web_crypto") @@ -374,7 +531,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Vm) { + let resolver = if capability_enabled!(Vm) { resolver .with_module("__wasm_rquickjs_builtin/vm_native") .with_module("__wasm_rquickjs_builtin/vm") @@ -384,13 +541,13 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::StructuredClone) { + let resolver = if capability_enabled!(StructuredClone) { resolver.with_module("__wasm_rquickjs_builtin/structured_clone") } else { resolver }; - let resolver = if cap(Capability::AsyncHooks) { + let resolver = if capability_enabled!(AsyncHooks) { resolver .with_module("node:async_hooks") .with_module("async_hooks") @@ -398,13 +555,13 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Cluster) { + let resolver = if capability_enabled!(Cluster) { resolver.with_module("node:cluster").with_module("cluster") } else { resolver }; - let resolver = if cap(Capability::Constants) { + let resolver = if capability_enabled!(Constants) { resolver .with_module("node:constants") .with_module("constants") @@ -412,7 +569,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Dgram) { + let resolver = if capability_enabled!(Dgram) { resolver .with_module("__wasm_rquickjs_builtin/dgram_native") .with_module("node:dgram") @@ -421,7 +578,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::DiagnosticsChannel) { + let resolver = if capability_enabled!(DiagnosticsChannel) { resolver .with_module("node:diagnostics_channel") .with_module("diagnostics_channel") @@ -429,7 +586,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Dns) { + let resolver = if capability_enabled!(Dns) { resolver .with_module("__wasm_rquickjs_builtin/dns_native") .with_module("node:dns") @@ -440,25 +597,25 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Domain) { + let resolver = if capability_enabled!(Domain) { resolver.with_module("node:domain").with_module("domain") } else { resolver }; - let resolver = if cap(Capability::Http2) { + let resolver = if capability_enabled!(Http2) { resolver.with_module("node:http2").with_module("http2") } else { resolver }; - let resolver = if cap(Capability::Https) { + let resolver = if capability_enabled!(Https) { resolver.with_module("node:https").with_module("https") } else { resolver }; - let resolver = if cap(Capability::Inspector) { + let resolver = if capability_enabled!(Inspector) { resolver .with_module("node:inspector") .with_module("inspector") @@ -466,7 +623,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::NodeHttp) { + let resolver = if capability_enabled!(NodeHttp) { resolver .with_module("__wasm_rquickjs_builtin/node_http_native") .with_module("__wasm_rquickjs_builtin/node_http_server") @@ -480,7 +637,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Net) { + let resolver = if capability_enabled!(Net) { resolver .with_module("__wasm_rquickjs_builtin/net_native") .with_module("node:net") @@ -489,7 +646,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::PerfHooks) { + let resolver = if capability_enabled!(PerfHooks) { resolver .with_module("node:perf_hooks") .with_module("perf_hooks") @@ -497,7 +654,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Readline) { + let resolver = if capability_enabled!(Readline) { resolver .with_module("node:readline") .with_module("readline") @@ -507,13 +664,13 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Repl) { + let resolver = if capability_enabled!(Repl) { resolver.with_module("node:repl").with_module("repl") } else { resolver }; - let resolver = if cap(Capability::TraceEvents) { + let resolver = if capability_enabled!(TraceEvents) { resolver .with_module("node:trace_events") .with_module("trace_events") @@ -521,25 +678,25 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Tls) { + let resolver = if capability_enabled!(Tls) { resolver.with_module("node:tls").with_module("tls") } else { resolver }; - let resolver = if cap(Capability::Tty) { + let resolver = if capability_enabled!(Tty) { resolver.with_module("node:tty").with_module("tty") } else { resolver }; - let resolver = if cap(Capability::V8) { + let resolver = if capability_enabled!(V8) { resolver.with_module("node:v8").with_module("v8") } else { resolver }; - let resolver = if cap(Capability::WorkerThreads) { + let resolver = if capability_enabled!(WorkerThreads) { resolver .with_module("node:worker_threads") .with_module("worker_threads") @@ -547,7 +704,7 @@ pub fn add_module_resolvers( resolver }; - let resolver = if cap(Capability::Zlib) { + let resolver = if capability_enabled!(Zlib) { resolver .with_module("__wasm_rquickjs_builtin/zlib_native") .with_module("node:zlib") @@ -557,7 +714,7 @@ pub fn add_module_resolvers( }; // SQLite - only node:sqlite, no bare "sqlite" (matches Node.js behavior) - let resolver = if cap(Capability::Sqlite) { + let resolver = if capability_enabled!(Sqlite) { resolver .with_module("__wasm_rquickjs_builtin/sqlite_native") .with_module("node:sqlite") @@ -566,7 +723,7 @@ pub fn add_module_resolvers( }; #[cfg(feature = "golem")] - let resolver = if cap(Capability::DiagnosticsChannel) { + let resolver = if capability_enabled!(DiagnosticsChannel) { resolver .with_module("__wasm_rquickjs_builtin/diagnostics_channel_native") .with_module("__wasm_rquickjs_builtin/diagnostics_channel_golem") @@ -580,7 +737,7 @@ pub fn add_module_resolvers( .with_module("__wasm_rquickjs_builtin/typescript_native"); #[cfg(feature = "websocket")] - let resolver = if cap(Capability::Websocket) { + let resolver = if capability_enabled!(Websocket) { resolver .with_module("__wasm_rquickjs_builtin/websocket_native") .with_module("__wasm_rquickjs_builtin/websocket") @@ -591,217 +748,190 @@ pub fn add_module_resolvers( internal::add_to_resolver(resolver) } -pub fn module_loader() -> ( - rquickjs::loader::ModuleLoader, - rquickjs::loader::BuiltinLoader, - rquickjs::loader::BuiltinLoader, -) { - // Native module registrations: gated by capability so that the underlying - // `js_native_module` reference becomes unreferenced when a capability is - // disabled, allowing wasm-level dead-code elimination to drop both the - // native function and its component-model imports. - - let native_loader = rquickjs::loader::ModuleLoader::default(); - - let native_loader = if cap(Capability::Base64) { - native_loader.with_module( - "__wasm_rquickjs_builtin/base64_native", - base64::js_native_module, - ) - } else { - native_loader - }; - - let native_loader = if cap(Capability::Console) { - native_loader.with_module( - "__wasm_rquickjs_builtin/console_native", - console::js_native_module, - ) - } else { - native_loader - }; - - let native_loader = if cap(Capability::Timers) { - native_loader.with_module( - "__wasm_rquickjs_builtin/timeout_native", - timeout::js_native_module, - ) - } else { - native_loader - }; - - let native_loader = if cap(Capability::Gc) { - native_loader.with_module("__wasm_rquickjs_builtin/gc_native", gc::js_native_module) - } else { - native_loader - }; - - let native_loader = if cap(Capability::NodeFetch) { - native_loader.with_module( - "__wasm_rquickjs_builtin/http_native", - http::js_native_module, - ) - } else { - native_loader - }; - - let native_loader = if cap(Capability::Encoding) { - native_loader.with_module( - "__wasm_rquickjs_builtin/encoding_native", - encoding::js_native_module, - ) - } else { - native_loader - }; - - let native_loader = if cap(Capability::Intl) { - native_loader.with_module( - "__wasm_rquickjs_builtin/intl_native", - intl::js_native_module, - ) - } else { - native_loader - }; - - let native_loader = if cap(Capability::Fs) { - native_loader.with_module("__wasm_rquickjs_builtin/fs_native", fs::js_native_module) - } else { - native_loader - }; - - let native_loader = if cap(Capability::Os) { - native_loader.with_module("__wasm_rquickjs_builtin/os_native", os::js_native_module) - } else { - native_loader - }; - - let native_loader = if cap(Capability::Process) { - native_loader.with_module( - "__wasm_rquickjs_builtin/process_native", - process::js_native_module, - ) - } else { - native_loader - }; - - // `internal/binding/util_native` is required by `util` and is treated as - // part of the Util capability — `util.js` re-imports it for low-level - // helpers like inspect. - let native_loader = if cap(Capability::Util) { - native_loader.with_module( - "__wasm_rquickjs_builtin/internal/binding/util_native", - internal_binding_util::js_native_module, - ) - } else { - native_loader - }; - - let native_loader = if cap(Capability::Url) { - native_loader.with_module("__wasm_rquickjs_builtin/url_native", url::js_native_module) - } else { - native_loader - }; - - let native_loader = if cap(Capability::WebCrypto) { - native_loader.with_module( - "__wasm_rquickjs_builtin/web_crypto_native", - web_crypto::js_native_module, - ) - } else { - native_loader - }; - - let native_loader = if cap(Capability::Vm) { - native_loader.with_module("__wasm_rquickjs_builtin/vm_native", vm::js_native_module) - } else { - native_loader - }; +fn module_from_declarations<'a, 'js>( + declarations: &'a rquickjs::module::Declarations<'js>, +) -> &'a rquickjs::Module<'js, rquickjs::module::Declared> { + debug_assert_eq!( + std::mem::size_of::>(), + std::mem::size_of::>() + ); + debug_assert_eq!( + std::mem::align_of::>(), + std::mem::align_of::>() + ); - let native_loader = if cap(Capability::Zlib) { - native_loader.with_module( - "__wasm_rquickjs_builtin/zlib_native", - zlib::js_native_module, - ) - } else { - native_loader - }; + // SAFETY: rquickjs 0.10 declares `Declarations<'js>` as a single-field + // tuple wrapper around `Module<'js, Declared>`: + // + // pub struct Declarations<'js>(Module<'js, Declared>); + // + // rquickjs does not expose the wrapped module publicly, but the unified + // builtin trampoline needs the current module name to dispatch back to the + // original per-builtin `ModuleDef`. Keep this cast isolated and guarded by + // the debug size/alignment assertions above. + unsafe { + &*(declarations as *const rquickjs::module::Declarations<'js> + as *const rquickjs::Module<'js, rquickjs::module::Declared>) + } +} - let native_loader = if cap(Capability::Dgram) { - native_loader.with_module( - "__wasm_rquickjs_builtin/dgram_native", - dgram::js_native_module, - ) - } else { - native_loader - }; +fn module_from_exports<'a, 'js>( + exports: &'a rquickjs::module::Exports<'js>, +) -> &'a rquickjs::Module<'js, rquickjs::module::Declared> { + debug_assert_eq!( + std::mem::size_of::>(), + std::mem::size_of::>() + ); + debug_assert_eq!( + std::mem::align_of::>(), + std::mem::align_of::>() + ); - let native_loader = if cap(Capability::Dns) { - native_loader.with_module("__wasm_rquickjs_builtin/dns_native", dns::js_native_module) - } else { - native_loader - }; + // SAFETY: Same layout assumption as `module_from_declarations`, for + // rquickjs' single-field `Exports<'js>(Module<'js, Declared>)` wrapper. + unsafe { + &*(exports as *const rquickjs::module::Exports<'js> + as *const rquickjs::Module<'js, rquickjs::module::Declared>) + } +} - let native_loader = if cap(Capability::NodeHttp) { - native_loader.with_module( - "__wasm_rquickjs_builtin/node_http_native", - node_http::js_native_module, - ) - } else { - native_loader - }; +fn declaration_module_name( + declarations: &rquickjs::module::Declarations<'_>, +) -> rquickjs::Result { + module_from_declarations(declarations).name::() +} - let native_loader = if cap(Capability::Net) { - native_loader.with_module("__wasm_rquickjs_builtin/net_native", net::js_native_module) - } else { - native_loader - }; +fn export_module_name(exports: &rquickjs::module::Exports<'_>) -> rquickjs::Result { + module_from_exports(exports).name::() +} - let native_loader = if cap(Capability::Sqlite) { - native_loader.with_module( - "__wasm_rquickjs_builtin/sqlite_native", - sqlite::js_native_module, - ) - } else { - native_loader +struct UnifiedBuiltinNativeModule; + +macro_rules! builtin_native_modules { + ($($(#[$meta:meta])* $cap:ident, $path:literal, $module:path;)*) => { + fn is_enabled_native_module_path(path: &str) -> bool { + $( + $(#[$meta])* + if capability_enabled!($cap) && path == $path { + return true; + } + )* + + false + } + + impl rquickjs::module::ModuleDef for UnifiedBuiltinNativeModule { + fn declare(declarations: &rquickjs::module::Declarations<'_>) -> rquickjs::Result<()> { + let name = declaration_module_name(declarations)?; + + $( + $(#[$meta])* + if capability_enabled!($cap) && name == $path { + return <$module as rquickjs::module::ModuleDef>::declare(declarations); + } + )* + + Err(rquickjs::Error::new_loading(name)) + } + + fn evaluate<'js>( + ctx: &rquickjs::Ctx<'js>, + exports: &rquickjs::module::Exports<'js>, + ) -> rquickjs::Result<()> { + let name = export_module_name(exports)?; + + $( + $(#[$meta])* + if capability_enabled!($cap) && name == $path { + return <$module as rquickjs::module::ModuleDef>::evaluate(ctx, exports); + } + )* + + Err(rquickjs::Error::new_loading(name)) + } + } }; +} - let native_loader = if cap(Capability::StringDecoder) { - native_loader.with_module( - "__wasm_rquickjs_builtin/string_decoder_native", - string_decoder::js_native_module, - ) - } else { - native_loader - }; +builtin_native_modules! { + Always, "__wasm_rquickjs_builtin/execution_native", execution::js_native_module; + Always, "__wasm_rquickjs_builtin/typescript_native", typescript::js_native_module; + Base64, "__wasm_rquickjs_builtin/base64_native", base64::js_native_module; + Console, "__wasm_rquickjs_builtin/console_native", console::js_native_module; + Timers, "__wasm_rquickjs_builtin/timeout_native", timeout::js_native_module; + Gc, "__wasm_rquickjs_builtin/gc_native", gc::js_native_module; + NodeFetch, "__wasm_rquickjs_builtin/http_native", http::js_native_module; + Encoding, "__wasm_rquickjs_builtin/encoding_native", encoding::js_native_module; + Intl, "__wasm_rquickjs_builtin/intl_native", intl::js_native_module; + Fs, "__wasm_rquickjs_builtin/fs_native", fs::js_native_module; + Os, "__wasm_rquickjs_builtin/os_native", os::js_native_module; + Process, "__wasm_rquickjs_builtin/process_native", process::js_native_module; + Util, "__wasm_rquickjs_builtin/internal/binding/util_native", internal_binding_util::js_native_module; + Url, "__wasm_rquickjs_builtin/url_native", url::js_native_module; + WebCrypto, "__wasm_rquickjs_builtin/web_crypto_native", web_crypto::js_native_module; + Vm, "__wasm_rquickjs_builtin/vm_native", vm::js_native_module; + Zlib, "__wasm_rquickjs_builtin/zlib_native", zlib::js_native_module; + Dgram, "__wasm_rquickjs_builtin/dgram_native", dgram::js_native_module; + Dns, "__wasm_rquickjs_builtin/dns_native", dns::js_native_module; + NodeHttp, "__wasm_rquickjs_builtin/node_http_native", node_http::js_native_module; + Net, "__wasm_rquickjs_builtin/net_native", net::js_native_module; + Sqlite, "__wasm_rquickjs_builtin/sqlite_native", sqlite::js_native_module; + StringDecoder, "__wasm_rquickjs_builtin/string_decoder_native", string_decoder::js_native_module; + #[cfg(feature = "golem")] + DiagnosticsChannel, "__wasm_rquickjs_builtin/diagnostics_channel_native", diagnostics_channel::js_native_module; + #[cfg(feature = "websocket")] + Websocket, "__wasm_rquickjs_builtin/websocket_native", websocket::js_native_module; +} - let native_loader = native_loader.with_module( - "__wasm_rquickjs_builtin/execution_native", - execution::js_native_module, - ); - let native_loader = native_loader.with_module( - "__wasm_rquickjs_builtin/typescript_native", - typescript::js_native_module, - ); +/// Custom native-module loader for builtins. +/// +/// Replaces the generic [`rquickjs::loader::ModuleLoader`] that previously +/// backed wasm-rquickjs' native builtin modules. The generic loader stores +/// one `fn(Ctx<'js>, Vec) -> Result>` per registered builtin +/// in a `HashMap`; each `with_module(..., X::js_native_module)` +/// monomorphises `ModuleLoader::load_func::` and the resulting function +/// pointer survives in the wasm function table even when the registering +/// `with_module` call is dead code. The single `call_indirect` in +/// `ModuleLoader::load` then keeps every type-compatible `load_func` +/// alive after wasm-eliminator DCE because the call's index operand is +/// runtime-dynamic, so all type-matching elem slots stay reachable. +/// +/// This loader avoids that retention by using exactly one native module type, +/// [`UnifiedBuiltinNativeModule`], for every builtin. The QuickJS callback table +/// therefore contains one rquickjs `eval_fn` instantiation instead of one per +/// builtin. The unified callback recovers the current module name from +/// rquickjs' declaration/export wrapper and dispatches through direct, +/// capability-gated calls to the original per-builtin `ModuleDef`. +pub struct BuiltinNativeLoader; + +impl rquickjs::loader::Loader for BuiltinNativeLoader { + fn load<'js>( + &mut self, + ctx: &rquickjs::Ctx<'js>, + path: &str, + ) -> rquickjs::Result> { + if is_enabled_native_module_path(path) { + return rquickjs::Module::declare_def::( + ctx.clone(), + Vec::from(path), + ); + } + + Err(rquickjs::Error::new_loading(path)) + } +} - #[cfg(feature = "golem")] - let native_loader = if cap(Capability::DiagnosticsChannel) { - native_loader.with_module( - "__wasm_rquickjs_builtin/diagnostics_channel_native", - diagnostics_channel::js_native_module, - ) - } else { - native_loader - }; +pub fn module_loader() -> ( + BuiltinNativeLoader, + rquickjs::loader::BuiltinLoader, + rquickjs::loader::BuiltinLoader, +) { + // Native module registrations are now handled by `BuiltinNativeLoader`'s + // direct match. The remaining loaders below cover JS source modules, + // which already store inert bytes per builtin and have no per-builtin + // monomorphisation issue. - #[cfg(feature = "websocket")] - let native_loader = if cap(Capability::Websocket) { - native_loader.with_module( - "__wasm_rquickjs_builtin/websocket_native", - websocket::js_native_module, - ) - } else { - native_loader - }; + let native_loader = BuiltinNativeLoader; // Builtin loader: registers JS source strings for each capability's // user-visible / internal modules. Mirrors the resolver gating above so @@ -810,7 +940,7 @@ pub fn module_loader() -> ( let builtin_loader = rquickjs::loader::BuiltinLoader::default(); - let builtin_loader = if cap(Capability::AbortController) { + let builtin_loader = if capability_enabled!(AbortController) { builtin_loader.with_module( "__wasm_rquickjs_builtin/abort_controller", abort_controller::ABORT_CONTROLLER_JS, @@ -819,7 +949,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Console) { + let builtin_loader = if capability_enabled!(Console) { builtin_loader .with_module("__wasm_rquickjs_builtin/console", console::CONSOLE_JS) .with_module("node:console", console::CONSOLE_JS) @@ -828,7 +958,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Timers) { + let builtin_loader = if capability_enabled!(Timers) { builtin_loader .with_module("__wasm_rquickjs_builtin/timeout", timeout::TIMEOUT_JS) .with_module("node:timers", timers::TIMERS_JS) @@ -839,7 +969,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::NodeFetch) { + let builtin_loader = if capability_enabled!(NodeFetch) { builtin_loader .with_module("__wasm_rquickjs_builtin/http_blob", http::FETCH_BLOB_JS) .with_module("__wasm_rquickjs_builtin/http_form_data", http::FORMDATA_JS) @@ -848,7 +978,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Webstreams) { + let builtin_loader = if capability_enabled!(Webstreams) { builtin_loader .with_module("__wasm_rquickjs_builtin/streams", webstreams::WEBSTREAMS_JS) .with_module( @@ -862,25 +992,25 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::FormDataNode) { + let builtin_loader = if capability_enabled!(FormDataNode) { builtin_loader.with_module("formdata-node", formdata_node::FORMDATA_NODE_JS) } else { builtin_loader }; - let builtin_loader = if cap(Capability::Encoding) { + let builtin_loader = if capability_enabled!(Encoding) { builtin_loader.with_module("__wasm_rquickjs_builtin/encoding", encoding::ENCODING_JS) } else { builtin_loader }; - let builtin_loader = if cap(Capability::Intl) { + let builtin_loader = if capability_enabled!(Intl) { builtin_loader.with_module("__wasm_rquickjs_builtin/intl", intl::INTL_JS) } else { builtin_loader }; - let builtin_loader = if cap(Capability::Util) { + let builtin_loader = if capability_enabled!(Util) { builtin_loader .with_module("node:util", util::UTIL_JS) .with_module("node:util/types", util::UTIL_TYPES_JS) @@ -890,7 +1020,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Buffer) { + let builtin_loader = if capability_enabled!(Buffer) { builtin_loader .with_module("base64-js", base64::BASE64_JS) .with_module("ieee754", ieee754::IEEE754_JS) @@ -900,7 +1030,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Fs) { + let builtin_loader = if capability_enabled!(Fs) { builtin_loader .with_module("node:fs", fs::FS_JS) .with_module("fs", fs::REEXPORT_JS) @@ -911,7 +1041,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Os) { + let builtin_loader = if capability_enabled!(Os) { builtin_loader .with_module("node:os", os::OS_JS) .with_module("os", os::REEXPORT_JS) @@ -919,7 +1049,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Assert) { + let builtin_loader = if capability_enabled!(Assert) { builtin_loader .with_module("node:assert", assert::ASSERT_JS) .with_module("assert", assert::REEXPORT_JS) @@ -929,7 +1059,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Querystring) { + let builtin_loader = if capability_enabled!(Querystring) { builtin_loader .with_module("node:querystring", querystring::QUERYSTRING_JS) .with_module("querystring", querystring::REEXPORT_JS) @@ -937,7 +1067,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::ChildProcess) { + let builtin_loader = if capability_enabled!(ChildProcess) { builtin_loader .with_module("node:child_process", child_process::CHILD_PROCESS_JS) .with_module("child_process", child_process::REEXPORT_JS) @@ -945,13 +1075,13 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::NodeTest) { + let builtin_loader = if capability_enabled!(NodeTest) { builtin_loader.with_module("node:test", node_test::TEST_JS) } else { builtin_loader }; - let builtin_loader = if cap(Capability::Module) { + let builtin_loader = if capability_enabled!(Module) { builtin_loader .with_module("node:module", module::MODULE_JS) .with_module("module", module::REEXPORT_JS) @@ -959,7 +1089,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Process) { + let builtin_loader = if capability_enabled!(Process) { builtin_loader .with_module("node:process", process::PROCESS_JS) .with_module("process", process::REEXPORT_JS) @@ -967,7 +1097,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Path) { + let builtin_loader = if capability_enabled!(Path) { builtin_loader .with_module("node:path", path::PATH_JS) .with_module("path", path::REEXPORT_JS) @@ -979,7 +1109,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Punycode) { + let builtin_loader = if capability_enabled!(Punycode) { builtin_loader .with_module("node:punycode", punycode::PUNYCODE_JS) .with_module("punycode", punycode::REEXPORT_JS) @@ -987,7 +1117,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Url) { + let builtin_loader = if capability_enabled!(Url) { builtin_loader .with_module("__wasm_rquickjs_builtin/url", url::URL_JS) .with_module("node:url", url::URL_JS) @@ -996,7 +1126,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Events) { + let builtin_loader = if capability_enabled!(Events) { builtin_loader .with_module("node:events", events::EVENTS_JS) .with_module("events", events::REEXPORT_JS) @@ -1004,7 +1134,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Stream) { + let builtin_loader = if capability_enabled!(Stream) { builtin_loader .with_module("node:stream", stream::STREAM_JS) .with_module("stream", stream::REEXPORT_JS) @@ -1016,7 +1146,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::StringDecoder) { + let builtin_loader = if capability_enabled!(StringDecoder) { builtin_loader .with_module("node:string_decoder", string_decoder::STRING_DECODER_JS) .with_module("string_decoder", string_decoder::REEXPORT_JS) @@ -1024,7 +1154,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::WebCrypto) { + let builtin_loader = if capability_enabled!(WebCrypto) { builtin_loader .with_module( "__wasm_rquickjs_builtin/web_crypto", @@ -1036,7 +1166,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Vm) { + let builtin_loader = if capability_enabled!(Vm) { builtin_loader .with_module("__wasm_rquickjs_builtin/vm", vm::VM_JS) .with_module("node:vm", vm::REEXPORT_JS) @@ -1045,7 +1175,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::StructuredClone) { + let builtin_loader = if capability_enabled!(StructuredClone) { builtin_loader.with_module( "__wasm_rquickjs_builtin/structured_clone", structured_clone::STRUCTURED_CLONE_JS, @@ -1054,7 +1184,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::AsyncHooks) { + let builtin_loader = if capability_enabled!(AsyncHooks) { builtin_loader .with_module("node:async_hooks", async_hooks::ASYNC_HOOKS_JS) .with_module("async_hooks", async_hooks::REEXPORT_JS) @@ -1062,7 +1192,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Cluster) { + let builtin_loader = if capability_enabled!(Cluster) { builtin_loader .with_module("node:cluster", cluster::CLUSTER_JS) .with_module("cluster", cluster::REEXPORT_JS) @@ -1070,7 +1200,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Constants) { + let builtin_loader = if capability_enabled!(Constants) { builtin_loader .with_module("node:constants", constants::CONSTANTS_JS) .with_module("constants", constants::REEXPORT_JS) @@ -1078,7 +1208,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Dgram) { + let builtin_loader = if capability_enabled!(Dgram) { builtin_loader .with_module("node:dgram", dgram::DGRAM_JS) .with_module("dgram", dgram::REEXPORT_JS) @@ -1086,7 +1216,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::DiagnosticsChannel) { + let builtin_loader = if capability_enabled!(DiagnosticsChannel) { builtin_loader .with_module( "node:diagnostics_channel", @@ -1097,7 +1227,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Dns) { + let builtin_loader = if capability_enabled!(Dns) { builtin_loader .with_module("node:dns", dns::DNS_JS) .with_module("dns", dns::REEXPORT_JS) @@ -1107,7 +1237,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Domain) { + let builtin_loader = if capability_enabled!(Domain) { builtin_loader .with_module("node:domain", domain::DOMAIN_JS) .with_module("domain", domain::REEXPORT_JS) @@ -1115,7 +1245,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::NodeHttp) { + let builtin_loader = if capability_enabled!(NodeHttp) { builtin_loader .with_module( "__wasm_rquickjs_builtin/node_http_server", @@ -1131,7 +1261,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Http2) { + let builtin_loader = if capability_enabled!(Http2) { builtin_loader .with_module("node:http2", http2::HTTP2_JS) .with_module("http2", http2::REEXPORT_JS) @@ -1139,7 +1269,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Https) { + let builtin_loader = if capability_enabled!(Https) { builtin_loader .with_module("node:https", https::HTTPS_JS) .with_module("https", https::REEXPORT_JS) @@ -1147,7 +1277,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Inspector) { + let builtin_loader = if capability_enabled!(Inspector) { builtin_loader .with_module("node:inspector", inspector::INSPECTOR_JS) .with_module("inspector", inspector::REEXPORT_JS) @@ -1155,7 +1285,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Net) { + let builtin_loader = if capability_enabled!(Net) { builtin_loader .with_module("node:net", net::NET_JS) .with_module("net", net::REEXPORT_JS) @@ -1163,7 +1293,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::PerfHooks) { + let builtin_loader = if capability_enabled!(PerfHooks) { builtin_loader .with_module("node:perf_hooks", perf_hooks::PERF_HOOKS_JS) .with_module("perf_hooks", perf_hooks::REEXPORT_JS) @@ -1171,7 +1301,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Readline) { + let builtin_loader = if capability_enabled!(Readline) { builtin_loader .with_module("node:readline", readline::READLINE_JS) .with_module("readline", readline::REEXPORT_JS) @@ -1181,7 +1311,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Repl) { + let builtin_loader = if capability_enabled!(Repl) { builtin_loader .with_module("node:repl", repl::REPL_JS) .with_module("repl", repl::REEXPORT_JS) @@ -1189,7 +1319,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::TraceEvents) { + let builtin_loader = if capability_enabled!(TraceEvents) { builtin_loader .with_module("node:trace_events", trace_events::TRACE_EVENTS_JS) .with_module("trace_events", trace_events::REEXPORT_JS) @@ -1197,7 +1327,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Tls) { + let builtin_loader = if capability_enabled!(Tls) { builtin_loader .with_module("node:tls", tls::TLS_JS) .with_module("tls", tls::REEXPORT_JS) @@ -1205,7 +1335,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Tty) { + let builtin_loader = if capability_enabled!(Tty) { builtin_loader .with_module("node:tty", tty::TTY_JS) .with_module("tty", tty::REEXPORT_JS) @@ -1213,7 +1343,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::V8) { + let builtin_loader = if capability_enabled!(V8) { builtin_loader .with_module("node:v8", v8::V8_JS) .with_module("v8", v8::REEXPORT_JS) @@ -1221,7 +1351,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::WorkerThreads) { + let builtin_loader = if capability_enabled!(WorkerThreads) { builtin_loader .with_module("node:worker_threads", worker_threads::WORKER_THREADS_JS) .with_module("worker_threads", worker_threads::REEXPORT_JS) @@ -1229,7 +1359,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Zlib) { + let builtin_loader = if capability_enabled!(Zlib) { builtin_loader .with_module("node:zlib", zlib::ZLIB_JS) .with_module("zlib", zlib::REEXPORT_JS) @@ -1237,7 +1367,7 @@ pub fn module_loader() -> ( builtin_loader }; - let builtin_loader = if cap(Capability::Sqlite) { + let builtin_loader = if capability_enabled!(Sqlite) { builtin_loader.with_module("node:sqlite", sqlite::SQLITE_JS) } else { builtin_loader @@ -1247,7 +1377,7 @@ pub fn module_loader() -> ( builtin_loader.with_module("wasm-rquickjs:execution", execution::EXECUTION_JS); #[cfg(feature = "golem")] - let builtin_loader = if cap(Capability::DiagnosticsChannel) { + let builtin_loader = if capability_enabled!(DiagnosticsChannel) { builtin_loader.with_module( "__wasm_rquickjs_builtin/diagnostics_channel_golem", diagnostics_channel::DIAGNOSTICS_CHANNEL_GOLEM_JS, @@ -1257,7 +1387,7 @@ pub fn module_loader() -> ( }; #[cfg(feature = "websocket")] - let builtin_loader = if cap(Capability::Websocket) { + let builtin_loader = if capability_enabled!(Websocket) { builtin_loader.with_module("__wasm_rquickjs_builtin/websocket", websocket::WEBSOCKET_JS) } else { builtin_loader @@ -1275,55 +1405,55 @@ pub fn wire_builtins() -> String { // drop both the corresponding `WIRE_JS` strings and any host imports they // would have transitively kept alive. - if cap(Capability::Events) { + if capability_enabled!(Events) { writeln!(result, "{}", events::WIRE_JS).unwrap(); } - if cap(Capability::AbortController) { + if capability_enabled!(AbortController) { writeln!(result, "{}", abort_controller::WIRE_JS).unwrap(); } - if cap(Capability::Base64) { + if capability_enabled!(Base64) { writeln!(result, "{}", base64::WIRE_JS).unwrap(); } - if cap(Capability::Buffer) { + if capability_enabled!(Buffer) { writeln!(result, "{}", buffer::WIRE_JS).unwrap(); } - if cap(Capability::Console) { + if capability_enabled!(Console) { writeln!(result, "{}", console::WIRE_JS).unwrap(); } - if cap(Capability::Timers) { + if capability_enabled!(Timers) { writeln!(result, "{}", timeout::WIRE_JS).unwrap(); } - if cap(Capability::Gc) { + if capability_enabled!(Gc) { writeln!(result, "{}", gc::WIRE_JS).unwrap(); } - if cap(Capability::NodeFetch) { + if capability_enabled!(NodeFetch) { writeln!(result, "{}", http::WIRE_JS).unwrap(); } - if cap(Capability::Webstreams) { + if capability_enabled!(Webstreams) { writeln!(result, "{}", webstreams::WIRE_JS).unwrap(); } - if cap(Capability::Encoding) { + if capability_enabled!(Encoding) { writeln!(result, "{}", encoding::WIRE_JS).unwrap(); } - if cap(Capability::Intl) { + if capability_enabled!(Intl) { writeln!(result, "{}", intl::WIRE_JS).unwrap(); } - if cap(Capability::Url) { + if capability_enabled!(Url) { writeln!(result, "{}", url::WIRE_JS).unwrap(); } - if cap(Capability::WebCrypto) { + if capability_enabled!(WebCrypto) { writeln!(result, "{}", web_crypto::WIRE_JS).unwrap(); } - if cap(Capability::Process) { + if capability_enabled!(Process) { writeln!(result, "{}", process::WIRE_JS).unwrap(); } - if cap(Capability::StructuredClone) { + if capability_enabled!(StructuredClone) { writeln!(result, "{}", structured_clone::WIRE_JS).unwrap(); } - if cap(Capability::Module) { + if capability_enabled!(Module) { writeln!(result, "{}", module::WIRE_JS).unwrap(); } - if cap(Capability::WorkerThreads) { + if capability_enabled!(WorkerThreads) { writeln!(result, "{}", worker_threads::WIRE_JS).unwrap(); } writeln!(result, "globalThis.global = globalThis;").unwrap(); @@ -1342,12 +1472,12 @@ pub fn wire_builtins() -> String { .unwrap(); #[cfg(feature = "golem")] - if cap(Capability::DiagnosticsChannel) { + if capability_enabled!(DiagnosticsChannel) { writeln!(result, "{}", diagnostics_channel::GOLEM_WIRE_JS).unwrap(); } #[cfg(feature = "websocket")] - if cap(Capability::Websocket) { + if capability_enabled!(Websocket) { writeln!(result, "{}", websocket::WIRE_JS).unwrap(); } diff --git a/crates/wasm-rquickjs/skeleton/src/capabilities.rs b/crates/wasm-rquickjs/skeleton/src/capabilities.rs index 3ad9b5b5a..e7375dfc1 100644 --- a/crates/wasm-rquickjs/skeleton/src/capabilities.rs +++ b/crates/wasm-rquickjs/skeleton/src/capabilities.rs @@ -1,18 +1,19 @@ //! Per-capability runtime gates. //! //! The skeleton ships with all builtins compiled in. Each builtin's registration -//! and global wiring is gated by a single bit in [`CAPABILITY_GATES_SLOT`]. By -//! default every bit is set, so every capability is wired up and behaves as if no -//! trimming were taking place. +//! and global wiring is gated by a tiny exported helper function. By default each +//! helper reads a bit in [`CAPABILITY_GATES_SLOT`], so every capability is wired +//! up and behaves as if no trimming were taking place. //! //! After the skeleton has been compiled to wasm, the `wasm-rquickjs` host tooling -//! can patch the bitset inside the slot to clear bits for capabilities the user's -//! JavaScript provably does not need (see `wasm_rquickjs::inject`). Once a bit is +//! can lower calls to those helper functions into immutable wasm `i32` globals +//! initialized to `0` or `1` (see `wasm_rquickjs::inject`). Once a global is //! cleared, the corresponding native module is not registered, the wrapper JS is //! not loaded, and the global wiring code is skipped. Combined with a downstream -//! wasm-level dead-code-elimination pass that drops unreferenced WIT imports, -//! this lets a precompiled base image shed the WASI surface (filesystem, sockets, -//! http, ...) of any builtin that the user app does not actually use. +//! wasm-level dead-code-elimination pass that folds immutable globals and drops +//! unreferenced WIT imports, this lets a precompiled base image shed the WASI +//! surface (filesystem, sockets, http, ...) of any builtin that the user app does +//! not actually use. //! //! ## Slot layout //! @@ -27,6 +28,8 @@ //! //! All reads of the bitset go through `core::ptr::read_volatile` to defeat //! constant-folding: the value is decided post-compile, not at LLVM time. +//! The slot remains as a fallback for tools that have not yet adopted the +//! helper-to-global lowering pass. //! //! ## Adding capabilities //! @@ -80,8 +83,7 @@ const fn build_capability_gates_slot() -> [u8; CAPABILITY_GATES_SLOT_SIZE] { /// guarantees the bytes end up in a data segment instead of being inlined. #[unsafe(no_mangle)] #[unsafe(link_section = ".wasm_rquickjs_capability_gates")] -pub static CAPABILITY_GATES_SLOT: [u8; CAPABILITY_GATES_SLOT_SIZE] = - build_capability_gates_slot(); +pub static CAPABILITY_GATES_SLOT: [u8; CAPABILITY_GATES_SLOT_SIZE] = build_capability_gates_slot(); /// Read the patched gate bitset from the slot via volatile reads to prevent /// LLVM from constant-folding the default value into callers. @@ -128,6 +130,73 @@ pub fn is_enabled(cap: Capability) -> bool { (cached_gates() >> bit) & 1 == 1 } +macro_rules! capability_gate_helpers { + ($($variant:ident => $fn_name:ident, $export_name:literal;)*) => { + $( + #[inline(never)] + #[unsafe(export_name = $export_name)] + pub extern "C" fn $fn_name() -> bool { + is_enabled(Capability::$variant) + } + )* + }; +} + +capability_gate_helpers! { + AbortController => cap_abort_controller, "__wrjs_cap_abort_controller"; + Assert => cap_assert, "__wrjs_cap_assert"; + AsyncHooks => cap_async_hooks, "__wrjs_cap_async_hooks"; + Base64 => cap_base64, "__wrjs_cap_base64"; + Buffer => cap_buffer, "__wrjs_cap_buffer"; + ChildProcess => cap_child_process, "__wrjs_cap_child_process"; + Cluster => cap_cluster, "__wrjs_cap_cluster"; + Console => cap_console, "__wrjs_cap_console"; + Constants => cap_constants, "__wrjs_cap_constants"; + Dgram => cap_dgram, "__wrjs_cap_dgram"; + DiagnosticsChannel => cap_diagnostics_channel, "__wrjs_cap_diagnostics_channel"; + Dns => cap_dns, "__wrjs_cap_dns"; + Domain => cap_domain, "__wrjs_cap_domain"; + Encoding => cap_encoding, "__wrjs_cap_encoding"; + Events => cap_events, "__wrjs_cap_events"; + FormDataNode => cap_formdata_node, "__wrjs_cap_formdata_node"; + Fs => cap_fs, "__wrjs_cap_fs"; + Gc => cap_gc, "__wrjs_cap_gc"; + Http2 => cap_http2, "__wrjs_cap_http2"; + Https => cap_https, "__wrjs_cap_https"; + Inspector => cap_inspector, "__wrjs_cap_inspector"; + Intl => cap_intl, "__wrjs_cap_intl"; + Module => cap_module, "__wrjs_cap_module"; + Net => cap_net, "__wrjs_cap_net"; + NodeFetch => cap_node_fetch, "__wrjs_cap_node_fetch"; + NodeHttp => cap_node_http, "__wrjs_cap_node_http"; + NodeTest => cap_node_test, "__wrjs_cap_node_test"; + Os => cap_os, "__wrjs_cap_os"; + Path => cap_path, "__wrjs_cap_path"; + PerfHooks => cap_perf_hooks, "__wrjs_cap_perf_hooks"; + Process => cap_process, "__wrjs_cap_process"; + Punycode => cap_punycode, "__wrjs_cap_punycode"; + Querystring => cap_querystring, "__wrjs_cap_querystring"; + Readline => cap_readline, "__wrjs_cap_readline"; + Repl => cap_repl, "__wrjs_cap_repl"; + Sqlite => cap_sqlite, "__wrjs_cap_sqlite"; + Stream => cap_stream, "__wrjs_cap_stream"; + StringDecoder => cap_string_decoder, "__wrjs_cap_string_decoder"; + StructuredClone => cap_structured_clone, "__wrjs_cap_structured_clone"; + Timers => cap_timers, "__wrjs_cap_timers"; + Tls => cap_tls, "__wrjs_cap_tls"; + TraceEvents => cap_trace_events, "__wrjs_cap_trace_events"; + Tty => cap_tty, "__wrjs_cap_tty"; + Url => cap_url, "__wrjs_cap_url"; + Util => cap_util, "__wrjs_cap_util"; + V8 => cap_v8, "__wrjs_cap_v8"; + Vm => cap_vm, "__wrjs_cap_vm"; + WebCrypto => cap_web_crypto, "__wrjs_cap_web_crypto"; + Websocket => cap_websocket, "__wrjs_cap_websocket"; + Webstreams => cap_webstreams, "__wrjs_cap_webstreams"; + WorkerThreads => cap_worker_threads, "__wrjs_cap_worker_threads"; + Zlib => cap_zlib, "__wrjs_cap_zlib"; +} + /// Identifiers for each builtin capability the skeleton can be asked to enable /// or disable at runtime. /// diff --git a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs index f8efeb0fb..f780bdcf1 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs @@ -10697,32 +10697,6 @@ pub(crate) async fn initialize_module_loading(rt: &AsyncRuntime, ctx: &AsyncCont let builtin_resolver = crate::modules::add_native_module_resolvers(builtin_resolver); let builtin_resolver = crate::builtin::add_module_resolvers(builtin_resolver); - let file_resolver = FileResolver::default() - .with_path("/") - .with_pattern("{}.js") - .with_pattern("{}.mjs") - .with_pattern("{}.json"); - let loader_cjs_facades = LoaderCjsFacadeRegistry::default(); - - let resolver = ( - ( - RealmGuardResolver, - MockModuleResolver, - DataUrlResolver, - FileUrlResolver, - PrivateBuiltinResolverGuard, - LoaderCjsFacadeResolver(loader_cjs_facades.clone()), - RegisteredLoaderResolver, - ), - ( - builtin_resolver, - NodeBuiltinNamespaceGuard, - NodeModulesResolver, - NodeFileResolver, - ), - (CjsEvalResolver, file_resolver, NodeModuleErrorResolver), - ); - let mut virtual_builtin_loader = VirtualBuiltinModuleLoader::default().with_module( crate::JS_EXPORT_MODULE_NAME, virtual_builtin_module_source(crate::js_export_module()), @@ -10734,20 +10708,73 @@ pub(crate) async fn initialize_module_loading(rt: &AsyncRuntime, ctx: &AsyncCont ); } - let loader = ( - ( + #[cfg(feature = "p2")] + let fs_enabled = crate::capabilities::cap_fs(); + #[cfg(feature = "p3")] + let fs_enabled = true; + + if fs_enabled { + let file_resolver = FileResolver::default() + .with_path("/") + .with_pattern("{}.js") + .with_pattern("{}.mjs") + .with_pattern("{}.json"); + let loader_cjs_facades = LoaderCjsFacadeRegistry::default(); + + let resolver = ( + ( + RealmGuardResolver, + MockModuleResolver, + DataUrlResolver, + FileUrlResolver, + PrivateBuiltinResolverGuard, + LoaderCjsFacadeResolver(loader_cjs_facades.clone()), + RegisteredLoaderResolver, + ), + ( + builtin_resolver, + NodeBuiltinNamespaceGuard, + NodeModulesResolver, + NodeFileResolver, + ), + (CjsEvalResolver, file_resolver, NodeModuleErrorResolver), + ); + + let loader = ( + ( + MockModuleLoader, + virtual_builtin_loader, + crate::modules::module_loader(), + crate::builtin::module_loader(), + LoaderCjsFacadeLoader(loader_cjs_facades.clone()), + DataUrlLoader, + StaticRegisteredFileUrlLoader, + ), + (JsonFileLoader, CjsCompatLoader, ImportMetaLoader), + ); + + rt.set_loader(resolver, loader).await; + } else { + let resolver = ( + ( + RealmGuardResolver, + MockModuleResolver, + DataUrlResolver, + PrivateBuiltinResolverGuard, + ), + (builtin_resolver, NodeBuiltinNamespaceGuard), + NodeModuleErrorResolver, + ); + let loader = ( MockModuleLoader, virtual_builtin_loader, crate::modules::module_loader(), crate::builtin::module_loader(), - LoaderCjsFacadeLoader(loader_cjs_facades.clone()), DataUrlLoader, - StaticRegisteredFileUrlLoader, - ), - (JsonFileLoader, CjsCompatLoader, ImportMetaLoader), - ); + ); - rt.set_loader(resolver, loader).await; + rt.set_loader(resolver, loader).await; + } async_with!(ctx => |ctx| { let global = ctx.globals(); diff --git a/crates/wasm-rquickjs/src/capability_scan.rs b/crates/wasm-rquickjs/src/capability_scan.rs index d41027445..26e05971a 100644 --- a/crates/wasm-rquickjs/src/capability_scan.rs +++ b/crates/wasm-rquickjs/src/capability_scan.rs @@ -644,7 +644,7 @@ pub fn dependencies(cap: Capability) -> &'static [Capability] { Assert => &[Fs, Util], Buffer => &[StringDecoder], ChildProcess => &[Buffer, Events, Module, Path, Process], - Console => &[Buffer, Util], + Console => &[Buffer, Process, Util], Constants => &[WebCrypto, Fs, Os], Dgram => &[Buffer, Dns, Events, Process], Dns => &[Net], @@ -653,9 +653,10 @@ pub fn dependencies(cap: Capability) -> &'static [Capability] { FormDataNode => &[NodeFetch], Https => &[NodeHttp], Inspector => &[Events], + Module => &[Vm], Net => &[Buffer, Dns, Events, Fs, Path], - NodeFetch => &[AbortController, Buffer], - NodeHttp => &[Buffer, DiagnosticsChannel, Events, Net], + NodeFetch => &[AbortController, Base64, Buffer, Encoding, NodeHttp, Webstreams], + NodeHttp => &[Buffer, DiagnosticsChannel, Events, Net, Timers, Url], NodeTest => &[Assert], Process => &[Events], Querystring => &[Buffer], @@ -667,11 +668,11 @@ pub fn dependencies(cap: Capability) -> &'static [Capability] { Tty => &[Net], Url => &[Querystring], Util => &[Encoding, WebCrypto], - WebCrypto => &[AbortController, Buffer], + WebCrypto => &[AbortController, Base64, Buffer], Zlib => &[Buffer, Stream], // Caps with no inter-cap dependencies (only internal/native deps). AsyncHooks | Base64 | Cluster | DiagnosticsChannel | Events | Fs | Gc | Http2 | Intl - | Module | Os | Path | PerfHooks | Punycode | Readline | Repl | Sqlite + | Os | Path | PerfHooks | Punycode | Readline | Repl | Sqlite | StructuredClone | V8 | Vm | Websocket | Webstreams | WorkerThreads => &[], } } @@ -1150,6 +1151,12 @@ mod tests { assert!(!r.used.contains(&Capability::FormDataNode)); } + #[test] + fn global_readable_stream_maps_to_webstreams() { + let r = scan(r#"const stream = new ReadableStream({});"#); + assert!(r.used.contains(&Capability::Webstreams)); + } + #[test] fn global_event_target_maps_to_events() { let r = scan(r#"class X extends EventTarget {}"#); @@ -1196,8 +1203,33 @@ mod tests { let c = closure(&seed); assert!(c.contains(&Capability::AbortController)); assert!(c.contains(&Capability::Events)); // from AbortController + assert!(c.contains(&Capability::Base64)); assert!(c.contains(&Capability::Buffer)); assert!(c.contains(&Capability::StringDecoder)); // from Buffer + assert!(c.contains(&Capability::Encoding)); + assert!(c.contains(&Capability::NodeHttp)); + assert!(c.contains(&Capability::Net)); // from NodeHttp + assert!(c.contains(&Capability::Webstreams)); + } + + #[test] + fn closure_pulls_console_deps() { + let mut seed = BTreeSet::new(); + seed.insert(Capability::Console); + let c = closure(&seed); + assert!(c.contains(&Capability::Buffer)); + assert!(c.contains(&Capability::Process)); + assert!(c.contains(&Capability::Util)); + } + + #[test] + fn closure_pulls_web_crypto_deps() { + let mut seed = BTreeSet::new(); + seed.insert(Capability::WebCrypto); + let c = closure(&seed); + assert!(c.contains(&Capability::AbortController)); + assert!(c.contains(&Capability::Base64)); + assert!(c.contains(&Capability::Buffer)); } #[test] @@ -1207,6 +1239,16 @@ mod tests { let c = closure(&seed); assert!(c.contains(&Capability::Net)); assert!(c.contains(&Capability::Dns)); + assert!(c.contains(&Capability::Timers)); + assert!(c.contains(&Capability::Url)); + } + + #[test] + fn closure_module_pulls_vm() { + let mut seed = BTreeSet::new(); + seed.insert(Capability::Module); + let c = closure(&seed); + assert!(c.contains(&Capability::Vm)); } #[test] diff --git a/crates/wasm-rquickjs/src/inject.rs b/crates/wasm-rquickjs/src/inject.rs index 47ca3fdc8..9d77dceee 100644 --- a/crates/wasm-rquickjs/src/inject.rs +++ b/crates/wasm-rquickjs/src/inject.rs @@ -1,7 +1,25 @@ +use std::borrow::Cow; +use std::collections::{BTreeMap, BTreeSet}; + use anyhow::{Context, anyhow}; use camino::Utf8Path; use wasm_encoder::reencode::{Error, Reencode, ReencodeComponent}; +use crate::capability_scan::{ALL_CAPABILITIES, Capability}; + +const CAPABILITY_ROOTS_SECTION: &str = "wasm-rquickjs.capability-roots"; +const CAPABILITY_ROOTS_MAGIC: &[u8; 8] = b"WRQJSCAP"; +const CAPABILITY_ROOTS_VERSION: u8 = 1; +const ROOT_KIND_INDIRECT_ANY: u8 = 0; +const ROOT_KIND_DIRECT_SCRUB: u8 = 1; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +struct CapabilityRootEntry { + func: u32, + capability: Capability, + kind: u8, +} + /// Magic bytes identifying a wasm-rquickjs JS injection marker. pub const SLOT_MAGIC: &[u8; 16] = b"WASM_RQJS_SLOT\x01\x00"; @@ -336,6 +354,8 @@ fn patch_js_offsets_in_output(output: &mut [u8], offsets: &[(u32, u32)]) -> anyh // Capability gates patching // --------------------------------------------------------------------------- +const CAPABILITY_HELPER_EXPORT_PREFIX: &str = "__wrjs_cap_"; + /// Magic prefix of the capability-gates slot embedded by the skeleton. pub const CAPABILITY_GATES_MAGIC: &[u8; 16] = b"WASM_RQJS_CAPS\x01\x00"; @@ -391,7 +411,21 @@ fn find_capability_gates_slots(wasm: &[u8]) -> Vec { /// Returns an error if the capability-gates marker is not present in `wasm`, /// which generally means the skeleton was built without the capability-gates /// support or the slot has been stripped already. -pub fn patch_capability_gates_in_bytes( +pub fn patch_capability_gates_in_bytes(wasm: &[u8], enabled_bits: u64) -> anyhow::Result> { + match lower_capability_helpers_to_globals(wasm, enabled_bits) { + Ok(lowered) if lowered.calls_rewritten > 0 => { + return match patch_capability_gates_slots_in_bytes(&lowered.bytes, enabled_bits) { + Ok(bytes) => Ok(bytes), + Err(_) => Ok(lowered.bytes), + }; + } + Ok(_) | Err(_) => {} + } + + patch_capability_gates_slots_in_bytes(wasm, enabled_bits) +} + +fn patch_capability_gates_slots_in_bytes( wasm: &[u8], enabled_bits: u64, ) -> anyhow::Result> { @@ -411,6 +445,542 @@ pub fn patch_capability_gates_in_bytes( Ok(out) } +struct LoweredCapabilityGlobals { + bytes: Vec, + calls_rewritten: usize, +} + +#[derive(Default)] +struct CapabilityGlobalModuleState { + imported_globals: u32, + defined_globals: u32, + cap_globals_base: Option, + appended_cap_globals: bool, + exported_helpers: BTreeMap, +} + +#[derive(Default)] +struct CapabilityGlobalLowerer { + enabled_bits: u64, + module: CapabilityGlobalModuleState, + calls_rewritten: usize, +} + +fn lower_capability_helpers_to_globals( + wasm: &[u8], + enabled_bits: u64, +) -> anyhow::Result { + let mut lowerer = CapabilityGlobalLowerer { + enabled_bits, + ..Default::default() + }; + let parser = wasmparser_encoder::Parser::new(0); + let mut component = wasm_encoder::Component::new(); + lowerer + .parse_component(&mut component, parser, wasm) + .map_err(|e| match e { + Error::UserError(e) => e, + Error::ParseError(e) => anyhow!("Failed to parse WASM component: {e}"), + other => anyhow!("Failed to reencode WASM component: {other}"), + })?; + + Ok(LoweredCapabilityGlobals { + bytes: component.finish(), + calls_rewritten: lowerer.calls_rewritten, + }) +} + +fn build_capability_roots_metadata(module: &[u8]) -> Vec { + let names = function_names(module); + let helper_exports = capability_helper_exports(module); + let direct = direct_call_graph(module); + let mut memo: BTreeMap> = BTreeMap::new(); + let mut entries = BTreeSet::new(); + + for func in direct.keys().copied() { + for cap in reachable_builtin_capabilities( + func, + &names, + &helper_exports, + &direct, + &mut memo, + ) { + entries.insert(CapabilityRootEntry { + func, + capability: cap, + kind: ROOT_KIND_INDIRECT_ANY, + }); + } + if let Some(name) = names.get(&func) + && is_fs_loader_function(name) + { + entries.insert(CapabilityRootEntry { + func, + capability: Capability::Fs, + kind: ROOT_KIND_DIRECT_SCRUB, + }); + } + } + + if entries.is_empty() { + return Vec::new(); + } + + let mut out = Vec::with_capacity(CAPABILITY_ROOTS_MAGIC.len() + 1 + 4 + entries.len() * 6); + out.extend_from_slice(CAPABILITY_ROOTS_MAGIC); + out.push(CAPABILITY_ROOTS_VERSION); + out.extend_from_slice(&(entries.len() as u32).to_le_bytes()); + for entry in entries { + out.extend_from_slice(&entry.func.to_le_bytes()); + out.push(entry.capability.bit_index()); + out.push(entry.kind); + } + out +} + +fn capability_helper_exports(module: &[u8]) -> BTreeMap { + let mut out = BTreeMap::new(); + for payload in wasmparser_encoder::Parser::new(0).parse_all(module) { + let Ok(wasmparser_encoder::Payload::ExportSection(section)) = payload else { + continue; + }; + for export in section.into_iter().flatten() { + if export.kind == wasmparser_encoder::ExternalKind::Func + && let Some(cap) = capability_from_helper_export(export.name) + { + out.insert(export.index, cap); + } + } + } + out +} + +fn function_names(module: &[u8]) -> BTreeMap { + let mut out = BTreeMap::new(); + for payload in wasmparser_encoder::Parser::new(0).parse_all(module) { + let Ok(wasmparser_encoder::Payload::CustomSection(section)) = payload else { + continue; + }; + if section.name() != "name" { + continue; + } + let names = wasmparser_encoder::NameSectionReader::new(wasmparser_encoder::BinaryReader::new( + section.data(), + 0, + )); + for name in names.into_iter().flatten() { + if let wasmparser_encoder::Name::Function(map) = name { + for naming in map.into_iter().flatten() { + out.insert(naming.index, naming.name.to_string()); + } + } + } + } + out +} + +fn direct_call_graph(module: &[u8]) -> BTreeMap> { + let mut function_types = Vec::new(); + let mut imported_funcs = 0u32; + let mut bodies = Vec::new(); + for payload in wasmparser_encoder::Parser::new(0).parse_all(module) { + match payload { + Ok(wasmparser_encoder::Payload::ImportSection(reader)) => { + for imports in reader.into_iter().flatten() { + match imports { + wasmparser_encoder::Imports::Single(_, import) => { + imported_funcs += imported_func_count_for_type_ref(import.ty, 1); + } + wasmparser_encoder::Imports::Compact1 { items, .. } => { + for item in items.into_iter().flatten() { + imported_funcs += imported_func_count_for_type_ref(item.ty, 1); + } + } + wasmparser_encoder::Imports::Compact2 { ty, names, .. } => { + imported_funcs += imported_func_count_for_type_ref(ty, names.count()); + } + } + } + } + Ok(wasmparser_encoder::Payload::FunctionSection(reader)) => { + function_types.extend(reader.into_iter().flatten()); + } + Ok(wasmparser_encoder::Payload::CodeSectionEntry(body)) => { + bodies.push(body); + } + _ => {} + } + } + + let mut out = BTreeMap::new(); + for (slot, body) in bodies.into_iter().enumerate() { + if slot >= function_types.len() { + break; + } + let func = imported_funcs + slot as u32; + let mut calls = Vec::new(); + let Ok(mut reader) = body.get_operators_reader() else { + continue; + }; + while !reader.eof() { + let Ok(op) = reader.read() else { + break; + }; + match op { + wasmparser_encoder::Operator::Call { function_index } + | wasmparser_encoder::Operator::ReturnCall { function_index } => { + calls.push(function_index); + } + _ => {} + } + } + out.insert(func, calls); + } + out +} + +fn imported_func_count_for_type_ref(ty: wasmparser_encoder::TypeRef, names_count: u32) -> u32 { + match ty { + wasmparser_encoder::TypeRef::Func(_) | wasmparser_encoder::TypeRef::FuncExact(_) => { + names_count + } + _ => 0, + } +} + +fn reachable_builtin_capabilities( + func: u32, + names: &BTreeMap, + helper_exports: &BTreeMap, + direct: &BTreeMap>, + memo: &mut BTreeMap>, +) -> BTreeSet { + let mut visiting = BTreeSet::new(); + reachable_builtin_capabilities_inner( + func, + names, + helper_exports, + direct, + memo, + &mut visiting, + ) +} + +fn reachable_builtin_capabilities_inner( + func: u32, + names: &BTreeMap, + helper_exports: &BTreeMap, + direct: &BTreeMap>, + memo: &mut BTreeMap>, + visiting: &mut BTreeSet, +) -> BTreeSet { + if let Some(cached) = memo.get(&func) { + return cached.clone(); + } + if !visiting.insert(func) { + return BTreeSet::new(); + } + + let mut caps = BTreeSet::new(); + if let Some(name) = names.get(&func) + && let Some(cap) = callback_capability(name) + { + caps.insert(cap); + } + if let Some(cap) = helper_exports.get(&func) { + caps.insert(*cap); + } + if let Some(callees) = direct.get(&func) { + for callee in callees { + caps.extend(reachable_builtin_capabilities_inner( + *callee, + names, + helper_exports, + direct, + memo, + visiting, + )); + } + } + + visiting.remove(&func); + memo.insert(func, caps.clone()); + caps +} + +fn callback_capability(name: &str) -> Option { + let module = builtin_module_fragment(name)?; + Some(match module { + "base64" => Capability::Base64, + "console" => Capability::Console, + "dgram" => Capability::Dgram, + "diagnostics_channel" => Capability::DiagnosticsChannel, + "dns" => Capability::Dns, + "encoding" => Capability::Encoding, + "fs" => Capability::Fs, + "gc" => Capability::Gc, + "http" => Capability::NodeFetch, + "intl" => Capability::Intl, + "internal_binding_util" => Capability::Util, + "net" => Capability::Net, + "node_http" => Capability::NodeHttp, + "os" => Capability::Os, + "process" => Capability::Process, + "sqlite" => Capability::Sqlite, + "string_decoder" => Capability::StringDecoder, + "timeout" => Capability::Timers, + "url" => Capability::Url, + "vm" => Capability::Vm, + "web_crypto" => Capability::WebCrypto, + "websocket" => Capability::Websocket, + "zlib" => Capability::Zlib, + _ => return None, + }) +} + +fn builtin_module_fragment(name: &str) -> Option<&str> { + let rest = if let Some((_, rest)) = name.split_once("::builtin::") { + rest + } else if let Some((_, rest)) = name.split_once("..builtin..") { + rest + } else { + return None; + }; + + rest.split("::") + .next() + .and_then(|s| s.split("..").next()) + .filter(|s| !s.is_empty()) +} + +fn is_fs_loader_function(name: &str) -> bool { + [ + "__wasilibc_populate_preopens", + "std::fs::", + "std..fs..", + "std::sys::fs::", + "std..sys..fs..", + "std::os::wasi::fs::", + "std..os..wasi..fs..", + "wasip2::imports::wasi::filesystem::", + "wasip2..imports..wasi..filesystem..", + "wasi::filesystem::", + "wasi..filesystem..", + "::internal::NodeModulesResolver::", + "..internal..NodeModulesResolver..", + "::internal::FileUrlResolver::", + "..internal..FileUrlResolver..", + "::internal::CjsEvalResolver", + "..internal..CjsEvalResolver", + "::internal::JsonFileLoader", + "..internal..JsonFileLoader", + "::internal::CjsCompatLoader", + "..internal..CjsCompatLoader", + "::internal::ImportMetaLoader", + "..internal..ImportMetaLoader", + "rquickjs_core::loader::file_resolver", + "rquickjs_core..loader..file_resolver", + ] + .iter() + .any(|fragment| name.contains(fragment)) +} + +fn capability_from_helper_export(name: &str) -> Option { + name.strip_prefix(CAPABILITY_HELPER_EXPORT_PREFIX) + .and_then(Capability::from_marker_name) +} + +fn imported_global_count_for_type_ref(ty: wasmparser_encoder::TypeRef, names_count: u32) -> u32 { + match ty { + wasmparser_encoder::TypeRef::Global(_) => names_count, + _ => 0, + } +} + +impl CapabilityGlobalLowerer { + fn append_capability_globals(&mut self, globals: &mut wasm_encoder::GlobalSection) { + if self.module.appended_cap_globals { + return; + } + + self.module.cap_globals_base = + Some(self.module.imported_globals + self.module.defined_globals); + for cap in ALL_CAPABILITIES { + let enabled = ((self.enabled_bits >> cap.bit_index()) & 1) as i32; + globals.global( + wasm_encoder::GlobalType { + val_type: wasm_encoder::ValType::I32, + mutable: false, + shared: false, + }, + &wasm_encoder::ConstExpr::i32_const(enabled), + ); + } + self.module.defined_globals += ALL_CAPABILITIES.len() as u32; + self.module.appended_cap_globals = true; + } + + fn capability_global_index(&self, cap: Capability) -> Option { + self.module + .cap_globals_base + .map(|base| base + cap.bit_index() as u32) + } +} + +impl Reencode for CapabilityGlobalLowerer { + type Error = anyhow::Error; + + fn parse_core_module( + &mut self, + module: &mut wasm_encoder::Module, + parser: wasmparser_encoder::Parser, + data: &[u8], + ) -> Result<(), Error> { + let outer = std::mem::take(&mut self.module); + let capability_roots = build_capability_roots_metadata(data); + let result = wasm_encoder::reencode::utils::parse_core_module(self, module, parser, data); + if result.is_ok() && !capability_roots.is_empty() { + module.section(&wasm_encoder::CustomSection { + name: Cow::Borrowed(CAPABILITY_ROOTS_SECTION), + data: Cow::Owned(capability_roots), + }); + } + self.module = outer; + result + } + + fn parse_custom_section( + &mut self, + module: &mut wasm_encoder::Module, + section: wasmparser_encoder::CustomSectionReader<'_>, + ) -> Result<(), Error> { + if section.name() == CAPABILITY_ROOTS_SECTION { + return Ok(()); + } + wasm_encoder::reencode::utils::parse_custom_section(self, module, section) + } + + fn parse_import_section( + &mut self, + import_section: &mut wasm_encoder::ImportSection, + section: wasmparser_encoder::ImportSectionReader<'_>, + ) -> Result<(), Error> { + for imports in section { + let imports = imports.map_err(Error::ParseError)?; + match imports.clone() { + wasmparser_encoder::Imports::Single(_, import) => { + self.module.imported_globals += + imported_global_count_for_type_ref(import.ty, 1); + } + wasmparser_encoder::Imports::Compact1 { items, .. } => { + for item in items { + let item = item.map_err(Error::ParseError)?; + self.module.imported_globals += + imported_global_count_for_type_ref(item.ty, 1); + } + } + wasmparser_encoder::Imports::Compact2 { ty, names, .. } => { + self.module.imported_globals += + imported_global_count_for_type_ref(ty, names.count()); + } + } + self.parse_imports(import_section, imports)?; + } + Ok(()) + } + + fn parse_global_section( + &mut self, + globals: &mut wasm_encoder::GlobalSection, + section: wasmparser_encoder::GlobalSectionReader<'_>, + ) -> Result<(), Error> { + let defined_count = section.count(); + wasm_encoder::reencode::utils::parse_global_section(self, globals, section)?; + self.module.defined_globals += defined_count; + self.append_capability_globals(globals); + Ok(()) + } + + fn parse_export( + &mut self, + exports: &mut wasm_encoder::ExportSection, + export: wasmparser_encoder::Export<'_>, + ) -> Result<(), Error> { + if export.kind == wasmparser_encoder::ExternalKind::Func + && let Some(cap) = capability_from_helper_export(export.name) + { + self.module.exported_helpers.insert(export.index, cap); + return Ok(()); + } + + wasm_encoder::reencode::utils::parse_export(self, exports, export) + } + + fn instruction<'a>( + &mut self, + arg: wasmparser_encoder::Operator<'a>, + ) -> Result, Error> { + if let wasmparser_encoder::Operator::Call { function_index } = arg + && let Some(cap) = self.module.exported_helpers.get(&function_index).copied() + && let Some(global_index) = self.capability_global_index(cap) + { + self.calls_rewritten += 1; + return Ok(wasm_encoder::Instruction::GlobalGet(global_index)); + } + + wasm_encoder::reencode::utils::instruction(self, arg) + } + + fn intersperse_section_hook( + &mut self, + module: &mut wasm_encoder::Module, + after: Option, + before: Option, + ) -> Result<(), Error> { + let global_id = wasm_encoder::SectionId::Global as u8; + let after_id = after.map(|id| id as u8).unwrap_or(0); + let before_id = before.map(|id| id as u8).unwrap_or(u8::MAX); + if !self.module.appended_cap_globals && after_id < global_id && before_id > global_id { + let mut globals = wasm_encoder::GlobalSection::new(); + self.append_capability_globals(&mut globals); + module.section(&globals); + } + + wasm_encoder::reencode::utils::intersperse_section_hook(self, module, after, before) + } +} + +impl ReencodeComponent for CapabilityGlobalLowerer { + fn parse_component_submodule( + &mut self, + component: &mut wasm_encoder::Component, + parser: wasmparser_encoder::Parser, + data: &[u8], + ) -> Result<(), Error> { + self.push_depth(); + let outer = std::mem::take(&mut self.module); + let capability_roots = build_capability_roots_metadata(data); + let mut module = wasm_encoder::Module::new(); + let result = wasm_encoder::reencode::utils::parse_core_module( + self, + &mut module, + parser, + data, + ); + if result.is_ok() && !capability_roots.is_empty() { + module.section(&wasm_encoder::CustomSection { + name: Cow::Borrowed(CAPABILITY_ROOTS_SECTION), + data: Cow::Owned(capability_roots), + }); + } + self.module = outer; + self.pop_depth(); + result?; + component.section(&wasm_encoder::ModuleSection(&module)); + Ok(()) + } +} + /// Read the current capability-gates bitset from `wasm`. Useful for round-trip /// testing and for letting callers see what the slot was patched with last. /// @@ -563,7 +1133,10 @@ mod tests { let patched = patch_capability_gates_in_bytes(&wasm, new_bits).unwrap(); // All slots should now hold the same patched value, so reading reports it. - assert_eq!(read_capability_gates_from_bytes(&patched).unwrap(), new_bits); + assert_eq!( + read_capability_gates_from_bytes(&patched).unwrap(), + new_bits + ); // Sanity: each individual slot in the patched buffer carries the new value. let bytes = new_bits.to_le_bytes(); @@ -584,11 +1157,17 @@ mod tests { // Patch to a specific bitset and confirm. let new_bits: u64 = 0xCAFE_BABE_DEAD_BEEF; let patched = patch_capability_gates_in_bytes(&wasm, new_bits).unwrap(); - assert_eq!(read_capability_gates_from_bytes(&patched).unwrap(), new_bits); + assert_eq!( + read_capability_gates_from_bytes(&patched).unwrap(), + new_bits + ); // Surrounding bytes must be untouched. assert_eq!(&patched[..200], &wasm[..200]); - assert_eq!(&patched[200 + CAPABILITY_GATES_SLOT_SIZE..], &wasm[200 + CAPABILITY_GATES_SLOT_SIZE..]); + assert_eq!( + &patched[200 + CAPABILITY_GATES_SLOT_SIZE..], + &wasm[200 + CAPABILITY_GATES_SLOT_SIZE..] + ); // Magic markers must be preserved across the patch. assert_eq!(&patched[200..200 + 16], CAPABILITY_GATES_MAGIC.as_slice()); @@ -598,6 +1177,106 @@ mod tests { ); } + fn build_test_component_with_capability_helper() -> Vec { + let mut module = wasm_encoder::Module::new(); + + let mut types = wasm_encoder::TypeSection::new(); + types.ty().function([], [wasm_encoder::ValType::I32]); + module.section(&types); + + let mut functions = wasm_encoder::FunctionSection::new(); + functions.function(0); + functions.function(0); + module.section(&functions); + + let mut exports = wasm_encoder::ExportSection::new(); + exports.export("__wrjs_cap_fs", wasm_encoder::ExportKind::Func, 0); + exports.export("caller", wasm_encoder::ExportKind::Func, 1); + module.section(&exports); + + let mut code = wasm_encoder::CodeSection::new(); + let mut helper = wasm_encoder::Function::new([]); + helper.instruction(&wasm_encoder::Instruction::I32Const(1)); + helper.instruction(&wasm_encoder::Instruction::End); + code.function(&helper); + + let mut caller = wasm_encoder::Function::new([]); + caller.instruction(&wasm_encoder::Instruction::Call(0)); + caller.instruction(&wasm_encoder::Instruction::End); + code.function(&caller); + module.section(&code); + + let mut component = wasm_encoder::Component::new(); + component.section(&wasm_encoder::ModuleSection(&module)); + component.finish() + } + + #[test] + fn test_lower_capability_helper_calls_to_globals() { + let component = build_test_component_with_capability_helper(); + let lowered = lower_capability_helpers_to_globals(&component, 0).unwrap(); + + assert_eq!(lowered.calls_rewritten, 1); + + let mut saw_disabled_fs_global = false; + let mut saw_rewritten_global_get = false; + let mut saw_helper_export = false; + + for payload in wasmparser_encoder::Parser::new(0).parse_all(&lowered.bytes) { + let payload = payload.unwrap(); + if let wasmparser_encoder::Payload::ModuleSection { + unchecked_range, .. + } = payload + { + let module_bytes = &lowered.bytes[unchecked_range]; + for module_payload in wasmparser_encoder::Parser::new(0).parse_all(module_bytes) { + match module_payload.unwrap() { + wasmparser_encoder::Payload::GlobalSection(section) => { + let globals: Vec<_> = + section.into_iter().collect::>().unwrap(); + assert_eq!(globals.len(), ALL_CAPABILITIES.len()); + let fs_global = &globals[Capability::Fs.bit_index() as usize]; + assert_eq!(fs_global.ty.content_type, wasmparser_encoder::ValType::I32); + assert!(!fs_global.ty.mutable); + + let mut ops = fs_global.init_expr.get_operators_reader(); + match ops.read().unwrap() { + wasmparser_encoder::Operator::I32Const { value } => { + saw_disabled_fs_global = value == 0; + } + other => panic!("unexpected fs global initializer: {other:?}"), + } + } + wasmparser_encoder::Payload::ExportSection(section) => { + for export in section { + let export = export.unwrap(); + if export.name == "__wrjs_cap_fs" { + saw_helper_export = true; + } + } + } + wasmparser_encoder::Payload::CodeSectionEntry(body) => { + let mut ops = body.get_operators_reader().unwrap(); + while !ops.eof() { + if let wasmparser_encoder::Operator::GlobalGet { global_index } = + ops.read().unwrap() + { + saw_rewritten_global_get = + global_index == Capability::Fs.bit_index() as u32; + } + } + } + _ => {} + } + } + } + } + + assert!(saw_disabled_fs_global); + assert!(saw_rewritten_global_get); + assert!(!saw_helper_export); + } + #[test] fn test_patch_capability_gates_no_marker() { let buf = vec![0u8; 100]; diff --git a/src/main.rs b/src/main.rs index c7ac40d79..f0f429f00 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,251 @@ use crate::cli::{Args, Command}; +use anyhow::Context; use clap::Parser; +use camino::Utf8Path; +use std::collections::{BTreeMap, BTreeSet}; +use wasm_rquickjs::capability_scan::ALL_CAPABILITIES; use wasm_rquickjs::{ EmbeddingMode, JsModuleSpec, generate_dts_with_target, generate_wrapper_crate_with_target, }; mod cli; +const CAPABILITY_ROOTS_SECTION: &str = "wasm-rquickjs.capability-roots"; +const CAPABILITY_ROOTS_MAGIC: &[u8; 8] = b"WRQJSCAP"; +const CAPABILITY_ROOTS_VERSION: u8 = 1; +const ROOT_KIND_INDIRECT_ANY: u8 = 0; +const ROOT_KIND_DIRECT_SCRUB: u8 = 1; + +fn auto_trim_dce_in_place(path: &Utf8Path, enabled_bits: u64) -> anyhow::Result<()> { + let before = std::fs::read(path.as_std_path()) + .with_context(|| format!("Failed to read injected component: {path}"))?; + let component_options = component_dce_options_from_capability_roots(&before, enabled_bits) + .context("building wasm-eliminator options from capability metadata")?; + let options = wasm_eliminator::DceOptions { + component: component_options, + ..Default::default() + }; + let after = wasm_eliminator::dce_with_options(&before, &options) + .context("running wasm-eliminator after capability gate patching")?; + std::fs::write(path.as_std_path(), &after) + .with_context(|| format!("Failed to write DCE'd component: {path}"))?; + eprintln!( + "wasm-eliminator auto-trim: {} B -> {} B", + before.len(), + after.len() + ); + Ok(()) +} + +fn component_dce_options_from_capability_roots( + bytes: &[u8], + enabled_bits: u64, +) -> anyhow::Result { + let ir = wasm_eliminator::component::ir::parse(bytes) + .context("parsing component to locate embedded core modules")?; + component_dce_options_from_ir(&ir, enabled_bits) +} + +fn component_dce_options_from_ir( + ir: &wasm_eliminator::component::ir::ComponentIr<'_>, + enabled_bits: u64, +) -> anyhow::Result { + let mut options = wasm_eliminator::component::DceOptions::default(); + + for (module_idx, module) in ir.module_entries.iter().enumerate() { + if let Some(hints) = producer_hints_from_capability_roots(module, enabled_bits)? { + options.module_hints.insert(module_idx as u32, hints); + } + } + + for (component_idx, component) in ir.nested_components.iter().enumerate() { + let child = component_dce_options_from_ir(component, enabled_bits)?; + if child != wasm_eliminator::component::DceOptions::default() { + options.nested_components.insert(component_idx as u32, child); + } + } + + Ok(options) +} + +fn producer_hints_from_capability_roots( + module: &[u8], + enabled_bits: u64, +) -> anyhow::Result> { + let mut hints = wasm_eliminator::core::analyze::ProducerHints::default(); + let mut saw_metadata = false; + hints.foldable_globals = capability_foldable_globals(module, enabled_bits)?; + + for payload in wasmparser_encoder::Parser::new(0).parse_all(module) { + let payload = payload.context("parsing embedded core module")?; + let wasmparser_encoder::Payload::CustomSection(section) = payload else { + continue; + }; + if section.name() != CAPABILITY_ROOTS_SECTION { + continue; + } + saw_metadata = true; + apply_capability_roots_section(section.data(), enabled_bits, &mut hints)?; + } + + if saw_metadata || !hints.foldable_globals.is_empty() { + Ok(Some(hints)) + } else { + Ok(None) + } +} + +fn capability_foldable_globals( + module: &[u8], + enabled_bits: u64, +) -> anyhow::Result> { + let mut imported_globals = 0u32; + let mut defined_i32_consts = Vec::new(); + + for payload in wasmparser_encoder::Parser::new(0).parse_all(module) { + match payload.context("parsing embedded core module")? { + wasmparser_encoder::Payload::ImportSection(section) => { + for imports in section { + imported_globals += imported_global_count_for_imports(imports?)?; + } + } + wasmparser_encoder::Payload::GlobalSection(section) => { + for (defined_idx, global) in section.into_iter().enumerate() { + let global = global?; + if global.ty.mutable { + continue; + } + let mut reader = global.init_expr.get_operators_reader(); + let value = match (reader.read(), reader.read()) { + ( + Ok(wasmparser_encoder::Operator::I32Const { value }), + Ok(wasmparser_encoder::Operator::End), + ) => value, + _ => continue, + }; + defined_i32_consts.push((imported_globals + defined_idx as u32, value)); + } + } + _ => {} + } + } + + let count = ALL_CAPABILITIES.len(); + if defined_i32_consts.len() < count { + return Ok(Default::default()); + } + let Some(window_start) = defined_i32_consts.windows(count).position(|window| { + window.iter().enumerate().all(|(idx, (_, value))| { + *value == ((enabled_bits >> ALL_CAPABILITIES[idx].bit_index()) & 1) as i32 + }) + }) else { + return Ok(Default::default()); + }; + let capability_globals = &defined_i32_consts[window_start..window_start + count]; + + Ok(capability_globals + .iter() + .map(|(global_idx, value)| { + ( + *global_idx, + wasm_eliminator::core::const_fold::ConstValue::I32(*value), + ) + }) + .collect()) +} + +fn imported_global_count_for_imports(imports: wasmparser_encoder::Imports<'_>) -> anyhow::Result { + Ok(match imports { + wasmparser_encoder::Imports::Single(_, import) => { + imported_global_count_for_type_ref(import.ty, 1) + } + wasmparser_encoder::Imports::Compact1 { items, .. } => { + let mut count = 0; + for item in items { + count += imported_global_count_for_type_ref(item?.ty, 1); + } + count + } + wasmparser_encoder::Imports::Compact2 { ty, names, .. } => { + imported_global_count_for_type_ref(ty, names.count()) + } + }) +} + +fn imported_global_count_for_type_ref(ty: wasmparser_encoder::TypeRef, names_count: u32) -> u32 { + match ty { + wasmparser_encoder::TypeRef::Global(_) => names_count, + _ => 0, + } +} + +fn apply_capability_roots_section( + data: &[u8], + enabled_bits: u64, + hints: &mut wasm_eliminator::core::analyze::ProducerHints, +) -> anyhow::Result<()> { + anyhow::ensure!( + data.len() >= CAPABILITY_ROOTS_MAGIC.len() + 1 + 4, + "malformed {CAPABILITY_ROOTS_SECTION}: header is too short" + ); + anyhow::ensure!( + &data[..CAPABILITY_ROOTS_MAGIC.len()] == CAPABILITY_ROOTS_MAGIC, + "malformed {CAPABILITY_ROOTS_SECTION}: bad magic" + ); + let version = data[CAPABILITY_ROOTS_MAGIC.len()]; + anyhow::ensure!( + version == CAPABILITY_ROOTS_VERSION, + "unsupported {CAPABILITY_ROOTS_SECTION} version {version}" + ); + + let mut offset = CAPABILITY_ROOTS_MAGIC.len() + 1; + let count = u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()) as usize; + offset += 4; + anyhow::ensure!( + data.len() == offset + count * 6, + "malformed {CAPABILITY_ROOTS_SECTION}: wrong payload length" + ); + + let mut roots: BTreeMap<(u32, u8), BTreeSet> = BTreeMap::new(); + for _ in 0..count { + let func = u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()); + let capability_bit = data[offset + 4]; + let kind = data[offset + 5]; + offset += 6; + + match kind { + ROOT_KIND_INDIRECT_ANY | ROOT_KIND_DIRECT_SCRUB => {} + other => anyhow::bail!( + "unsupported {CAPABILITY_ROOTS_SECTION} root kind {other}" + ), + } + + roots.entry((func, kind)).or_default().insert(capability_bit); + } + + for ((func, kind), capability_bits) in roots { + let all_disabled = capability_bits + .iter() + .all(|capability_bit| ((enabled_bits >> *capability_bit) & 1) == 0); + if !all_disabled { + continue; + } + + match kind { + ROOT_KIND_INDIRECT_ANY => { + hints.suppress_any_targets.insert(func); + } + ROOT_KIND_DIRECT_SCRUB => { + hints.suppress_any_targets.insert(func); + hints.scrub_direct_targets.insert(func); + } + _ => unreachable!("validated capability root kind while decoding"), + } + } + + Ok(()) +} + fn main() { let args = Args::parse(); match &args.command { @@ -315,6 +555,11 @@ fn main() { std::process::exit(1); } let _ = std::fs::remove_file(staging.as_std_path()); + + if let Err(err) = auto_trim_dce_in_place(output, bits) { + eprintln!("Error running wasm-eliminator auto-trim: {err:#}"); + std::process::exit(1); + } } else if let Err(err) = wasm_rquickjs::inject_js_into_component(input, output, &js_refs) { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index e8d3b16e4..06098961e 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -8,6 +8,7 @@ use camino_tempfile::{NamedUtf8TempFile, Utf8TempDir}; use futures::FutureExt; use heck::ToSnakeCase; use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::env; use std::fs; use std::hash::{Hash, Hasher}; use std::io::{Read, Write}; @@ -16,10 +17,14 @@ use std::sync::{Arc, Mutex, OnceLock}; use std::thread; use std::time::{Duration, Instant, SystemTime}; use tokio::time::timeout; +use wasm_rquickjs::capability_scan::{ + ALL_CAPABILITIES, Policy, ScanResult, apply_policy, enabled_bits, scan_entry_point, +}; use wac_graph::types::{Package, SubtypeChecker}; use wac_graph::{CompositionGraph, EncodeOptions, PackageId, PlugError}; use wasm_rquickjs::{ EmbeddingMode, GenerationTarget, JsModuleSpec, generate_wrapper_crate_with_target, + patch_capability_gates_in_bytes, }; use wasmtime::component::{ Component, Func, HasSelf, Instance, Linker, Resource, ResourceAny, ResourceTable, ResourceType, @@ -2098,6 +2103,12 @@ pub struct CompiledTest { wasm: WasmSource, } +const CAPABILITY_ROOTS_SECTION: &str = "wasm-rquickjs.capability-roots"; +const CAPABILITY_ROOTS_MAGIC: &[u8; 8] = b"WRQJSCAP"; +const CAPABILITY_ROOTS_VERSION: u8 = 1; +const ROOT_KIND_INDIRECT_ANY: u8 = 0; +const ROOT_KIND_DIRECT_SCRUB: u8 = 1; + impl CompiledTest { pub async fn new(path: &Utf8Path, use_shared_target: bool) -> anyhow::Result { Self::new_with_features(path, use_shared_target, FeatureCombination::Normal).await @@ -2123,6 +2134,7 @@ impl CompiledTest { } else { compiled.optimize().await? }; + let compiled = compiled.auto_trim_for_example_if_requested(path)?; if truthy_env(TEST_PRECOMPILE_COMPONENT_ENV) { let started = Instant::now(); if precompile_component(compiled.wasm_path())? { @@ -2486,6 +2498,282 @@ impl CompiledTest { wasm: WasmSource::OwnedTemporary(wasm_path), }) } + + fn auto_trim_for_example_if_requested( + &self, + example_path: &Utf8Path, + ) -> anyhow::Result { + if !env_flag("WASM_RQUICKJS_RUNTIME_AUTO_TRIM") { + return Ok(CompiledTest { + wasm: Precompiled(self.wasm_path().to_path_buf()), + }); + } + + let input = self.wasm_path(); + let output = input.with_extension("trimmed.wasm"); + let trim_unknown = env_flag("WASM_RQUICKJS_RUNTIME_TRIM_UNKNOWN"); + let bits = capability_bits_for_example(example_path, trim_unknown)?; + println!( + "Auto-trimming runtime component {input} -> {output} (trim_unknown={trim_unknown})" + ); + auto_trim_component(input, &output, bits)?; + Ok(CompiledTest { + wasm: Precompiled(output), + }) + } +} + +fn env_flag(name: &str) -> bool { + env::var(name) + .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES")) + .unwrap_or(false) +} + +fn capability_bits_for_example(example_path: &Utf8Path, trim_unknown: bool) -> anyhow::Result { + let name = example_path.file_name().expect("example path has a file name"); + let js_path = example_path.join("src").join(format!("{name}.js")); + let scan = if js_path.exists() { + scan_entry_point(&js_path) + } else { + let mut all_on = ScanResult::default(); + all_on.used.extend(ALL_CAPABILITIES.iter().copied()); + all_on + }; + let outcome = apply_policy( + &scan, + &Policy { + trim_unknown, + ..Policy::default() + }, + ); + if outcome.conservative_fallback { + println!( + "Auto-trim capability scan for {example_path} used conservative all-capabilities fallback" + ); + } + Ok(enabled_bits(outcome.enabled.iter().copied())) +} + +fn auto_trim_component(input: &Utf8Path, output: &Utf8Path, enabled_bits: u64) -> anyhow::Result<()> { + let before = fs::read(input.as_std_path())?; + let patched = patch_capability_gates_in_bytes(&before, enabled_bits)?; + let component_options = component_dce_options_from_capability_roots(&patched, enabled_bits)?; + let options = wasm_eliminator::DceOptions { + component: component_options, + ..Default::default() + }; + let after = wasm_eliminator::dce_with_options(&patched, &options)?; + fs::write(output.as_std_path(), &after)?; + println!("wasm-eliminator auto-trim: {} B -> {} B", before.len(), after.len()); + Ok(()) +} + +fn component_dce_options_from_capability_roots( + bytes: &[u8], + enabled_bits: u64, +) -> anyhow::Result { + let ir = wasm_eliminator::component::ir::parse(bytes)?; + component_dce_options_from_ir(&ir, enabled_bits) +} + +fn component_dce_options_from_ir( + ir: &wasm_eliminator::component::ir::ComponentIr<'_>, + enabled_bits: u64, +) -> anyhow::Result { + let mut options = wasm_eliminator::component::DceOptions::default(); + + for (module_idx, module) in ir.module_entries.iter().enumerate() { + if let Some(hints) = producer_hints_from_capability_roots(module, enabled_bits)? { + options.module_hints.insert(module_idx as u32, hints); + } + } + + for (component_idx, component) in ir.nested_components.iter().enumerate() { + let child = component_dce_options_from_ir(component, enabled_bits)?; + if child != wasm_eliminator::component::DceOptions::default() { + options.nested_components.insert(component_idx as u32, child); + } + } + + Ok(options) +} + +fn producer_hints_from_capability_roots( + module: &[u8], + enabled_bits: u64, +) -> anyhow::Result> { + let mut hints = wasm_eliminator::core::analyze::ProducerHints::default(); + let mut saw_metadata = false; + hints.foldable_globals = capability_foldable_globals(module, enabled_bits)?; + + for payload in wasmparser_encoder::Parser::new(0).parse_all(module) { + let payload = payload?; + let wasmparser_encoder::Payload::CustomSection(section) = payload else { + continue; + }; + if section.name() != CAPABILITY_ROOTS_SECTION { + continue; + } + saw_metadata = true; + apply_capability_roots_section(section.data(), enabled_bits, &mut hints)?; + } + + if saw_metadata || !hints.foldable_globals.is_empty() { + Ok(Some(hints)) + } else { + Ok(None) + } +} + +fn capability_foldable_globals( + module: &[u8], + enabled_bits: u64, +) -> anyhow::Result> { + let mut imported_globals = 0u32; + let mut defined_i32_consts = Vec::new(); + + for payload in wasmparser_encoder::Parser::new(0).parse_all(module) { + match payload? { + wasmparser_encoder::Payload::ImportSection(section) => { + for imports in section { + imported_globals += imported_global_count_for_imports(imports?)?; + } + } + wasmparser_encoder::Payload::GlobalSection(section) => { + for (defined_idx, global) in section.into_iter().enumerate() { + let global = global?; + if global.ty.mutable { + continue; + } + let mut reader = global.init_expr.get_operators_reader(); + let value = match (reader.read(), reader.read()) { + ( + Ok(wasmparser_encoder::Operator::I32Const { value }), + Ok(wasmparser_encoder::Operator::End), + ) => value, + _ => continue, + }; + defined_i32_consts.push((imported_globals + defined_idx as u32, value)); + } + } + _ => {} + } + } + + let count = ALL_CAPABILITIES.len(); + if defined_i32_consts.len() < count { + return Ok(BTreeMap::new()); + } + let Some(window_start) = defined_i32_consts.windows(count).position(|window| { + window.iter().enumerate().all(|(idx, (_, value))| { + *value == ((enabled_bits >> ALL_CAPABILITIES[idx].bit_index()) & 1) as i32 + }) + }) else { + return Ok(BTreeMap::new()); + }; + let capability_globals = &defined_i32_consts[window_start..window_start + count]; + + Ok(capability_globals + .iter() + .map(|(global_idx, value)| { + ( + *global_idx, + wasm_eliminator::core::const_fold::ConstValue::I32(*value), + ) + }) + .collect()) +} + +fn imported_global_count_for_imports(imports: wasmparser_encoder::Imports<'_>) -> anyhow::Result { + Ok(match imports { + wasmparser_encoder::Imports::Single(_, import) => { + imported_global_count_for_type_ref(import.ty, 1) + } + wasmparser_encoder::Imports::Compact1 { items, .. } => { + let mut count = 0; + for item in items { + count += imported_global_count_for_type_ref(item?.ty, 1); + } + count + } + wasmparser_encoder::Imports::Compact2 { ty, names, .. } => { + imported_global_count_for_type_ref(ty, names.count()) + } + }) +} + +fn imported_global_count_for_type_ref(ty: wasmparser_encoder::TypeRef, names_count: u32) -> u32 { + match ty { + wasmparser_encoder::TypeRef::Global(_) => names_count, + _ => 0, + } +} + +fn apply_capability_roots_section( + data: &[u8], + enabled_bits: u64, + hints: &mut wasm_eliminator::core::analyze::ProducerHints, +) -> anyhow::Result<()> { + anyhow::ensure!( + data.len() >= CAPABILITY_ROOTS_MAGIC.len() + 1 + 4, + "malformed {CAPABILITY_ROOTS_SECTION}: header is too short" + ); + anyhow::ensure!( + &data[..CAPABILITY_ROOTS_MAGIC.len()] == CAPABILITY_ROOTS_MAGIC, + "malformed {CAPABILITY_ROOTS_SECTION}: bad magic" + ); + let version = data[CAPABILITY_ROOTS_MAGIC.len()]; + anyhow::ensure!( + version == CAPABILITY_ROOTS_VERSION, + "unsupported {CAPABILITY_ROOTS_SECTION} version {version}" + ); + + let mut offset = CAPABILITY_ROOTS_MAGIC.len() + 1; + let count = u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()) as usize; + offset += 4; + anyhow::ensure!( + data.len() == offset + count * 6, + "malformed {CAPABILITY_ROOTS_SECTION}: wrong payload length" + ); + + let mut roots: BTreeMap<(u32, u8), BTreeSet> = BTreeMap::new(); + for _ in 0..count { + let func = u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()); + let capability_bit = data[offset + 4]; + let kind = data[offset + 5]; + offset += 6; + + match kind { + ROOT_KIND_INDIRECT_ANY | ROOT_KIND_DIRECT_SCRUB => {} + other => anyhow::bail!( + "unsupported {CAPABILITY_ROOTS_SECTION} root kind {other}" + ), + } + + roots.entry((func, kind)).or_default().insert(capability_bit); + } + + for ((func, kind), capability_bits) in roots { + let all_disabled = capability_bits + .iter() + .all(|capability_bit| ((enabled_bits >> *capability_bit) & 1) == 0); + if !all_disabled { + continue; + } + + match kind { + ROOT_KIND_INDIRECT_ANY => { + hints.suppress_any_targets.insert(func); + } + ROOT_KIND_DIRECT_SCRUB => { + hints.suppress_any_targets.insert(func); + hints.scrub_direct_targets.insert(func); + } + _ => unreachable!("validated capability root kind while decoding"), + } + } + + Ok(()) } #[derive(Clone)] From 248abc974eb71ee2b72d504c655441951f90d956 Mon Sep 17 00:00:00 2001 From: Daniel Vigovszky Date: Mon, 18 May 2026 16:14:00 +0200 Subject: [PATCH 3/3] WIP Co-authored-by: Daniel Vigovszky Amp-Thread-ID: https://ampcode.com/threads/T-01a03cfa-71be-7179-b632-02e48551ab84 --- .../skeleton/src/capabilities.rs | 3 + .../skeleton/src/internal/module_loading.rs | 5 +- crates/wasm-rquickjs/src/capability_scan.rs | 32 +- crates/wasm-rquickjs/src/inject.rs | 350 +++++++++++++++--- src/main.rs | 30 +- tests/binary_inject.rs | 10 +- tests/common/mod.rs | 37 +- 7 files changed, 386 insertions(+), 81 deletions(-) diff --git a/crates/wasm-rquickjs/skeleton/src/capabilities.rs b/crates/wasm-rquickjs/skeleton/src/capabilities.rs index e7375dfc1..e852fd39a 100644 --- a/crates/wasm-rquickjs/skeleton/src/capabilities.rs +++ b/crates/wasm-rquickjs/skeleton/src/capabilities.rs @@ -195,6 +195,7 @@ capability_gate_helpers! { Webstreams => cap_webstreams, "__wrjs_cap_webstreams"; WorkerThreads => cap_worker_threads, "__wrjs_cap_worker_threads"; Zlib => cap_zlib, "__wrjs_cap_zlib"; + FsModuleLoader => cap_fs_module_loader, "__wrjs_cap_fs_module_loader"; } /// Identifiers for each builtin capability the skeleton can be asked to enable @@ -262,6 +263,7 @@ pub enum Capability { Webstreams = 49, WorkerThreads = 50, Zlib = 51, + FsModuleLoader = 52, } impl Capability { @@ -322,6 +324,7 @@ impl Capability { Webstreams => "webstreams", WorkerThreads => "worker_threads", Zlib => "zlib", + FsModuleLoader => "fs_module_loader", } } } diff --git a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs index f780bdcf1..3be7a9fee 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs @@ -10709,17 +10709,18 @@ pub(crate) async fn initialize_module_loading(rt: &AsyncRuntime, ctx: &AsyncCont } #[cfg(feature = "p2")] - let fs_enabled = crate::capabilities::cap_fs(); + let fs_enabled = crate::capabilities::cap_fs_module_loader(); #[cfg(feature = "p3")] let fs_enabled = true; + let loader_cjs_facades = LoaderCjsFacadeRegistry::default(); + if fs_enabled { let file_resolver = FileResolver::default() .with_path("/") .with_pattern("{}.js") .with_pattern("{}.mjs") .with_pattern("{}.json"); - let loader_cjs_facades = LoaderCjsFacadeRegistry::default(); let resolver = ( ( diff --git a/crates/wasm-rquickjs/src/capability_scan.rs b/crates/wasm-rquickjs/src/capability_scan.rs index 26e05971a..649d1d8cb 100644 --- a/crates/wasm-rquickjs/src/capability_scan.rs +++ b/crates/wasm-rquickjs/src/capability_scan.rs @@ -108,6 +108,7 @@ pub enum Capability { Webstreams = 49, WorkerThreads = 50, Zlib = 51, + FsModuleLoader = 52, } /// Every capability the scanner knows about, in declaration order. @@ -165,6 +166,7 @@ pub const ALL_CAPABILITIES: &[Capability] = &[ Capability::Webstreams, Capability::WorkerThreads, Capability::Zlib, + Capability::FsModuleLoader, ]; impl Capability { @@ -239,6 +241,7 @@ impl Capability { Webstreams => "webstreams", WorkerThreads => "worker_threads", Zlib => "zlib", + FsModuleLoader => "fs_module_loader", } } } @@ -655,7 +658,14 @@ pub fn dependencies(cap: Capability) -> &'static [Capability] { Inspector => &[Events], Module => &[Vm], Net => &[Buffer, Dns, Events, Fs, Path], - NodeFetch => &[AbortController, Base64, Buffer, Encoding, NodeHttp, Webstreams], + NodeFetch => &[ + AbortController, + Base64, + Buffer, + Encoding, + NodeHttp, + Webstreams, + ], NodeHttp => &[Buffer, DiagnosticsChannel, Events, Net, Timers, Url], NodeTest => &[Assert], Process => &[Events], @@ -671,8 +681,8 @@ pub fn dependencies(cap: Capability) -> &'static [Capability] { WebCrypto => &[AbortController, Base64, Buffer], Zlib => &[Buffer, Stream], // Caps with no inter-cap dependencies (only internal/native deps). - AsyncHooks | Base64 | Cluster | DiagnosticsChannel | Events | Fs | Gc | Http2 | Intl - | Os | Path | PerfHooks | Punycode | Readline | Repl | Sqlite + AsyncHooks | Base64 | Cluster | DiagnosticsChannel | Events | Fs | FsModuleLoader | Gc + | Http2 | Intl | Os | Path | PerfHooks | Punycode | Readline | Repl | Sqlite | StructuredClone | V8 | Vm | Websocket | Webstreams | WorkerThreads => &[], } } @@ -769,9 +779,14 @@ impl<'src> Scanner<'src> { fn record_specifier(&mut self, raw: &str, span: Span) { if raw.starts_with('.') || raw.starts_with('/') { + self.out.used.insert(Capability::FsModuleLoader); self.warn(WarningKind::RelativeImport(raw.to_string()), span); return; } + if raw.starts_with("file:") { + self.out.used.insert(Capability::FsModuleLoader); + return; + } // `spec_to_cap` is checked first so `node:fs/promises` resolves to Fs // before the WIT-style check would mistakenly catch it on the `/`. if let Some(cap) = spec_to_cap(raw) { @@ -1347,9 +1362,20 @@ mod tests { std::fs::write(&entry, "import './helper.js';\n").unwrap(); let r = scan_entry_point(&entry); assert!(r.used.contains(&Capability::Fs)); + assert!(r.used.contains(&Capability::FsModuleLoader)); assert!(r.warnings.is_empty(), "warnings: {:?}", r.warnings); } + #[test] + fn file_url_import_uses_fs_module_loader_without_node_fs() { + let r = scan_module( + Utf8Path::new("entry.js"), + "import 'file:///tmp/plugin.mjs';\n", + ); + assert!(r.used.contains(&Capability::FsModuleLoader)); + assert!(!r.used.contains(&Capability::Fs)); + } + #[test] fn entry_point_resolves_extensionless() { let dir = camino_tempfile_workaround(); diff --git a/crates/wasm-rquickjs/src/inject.rs b/crates/wasm-rquickjs/src/inject.rs index 9d77dceee..55a41b4ab 100644 --- a/crates/wasm-rquickjs/src/inject.rs +++ b/crates/wasm-rquickjs/src/inject.rs @@ -7,6 +7,17 @@ use wasm_encoder::reencode::{Error, Reencode, ReencodeComponent}; use crate::capability_scan::{ALL_CAPABILITIES, Capability}; +/// Custom section emitted by wasm-rquickjs to describe capability-owned opaque +/// roots to the generic wasm-eliminator. +/// +/// The section is a producer-owned conditional reachability contract: a listed +/// function/resource shim belongs to one capability, and callers may suppress it +/// only when that capability is disabled. Shared implementation glue is emitted +/// once per owner capability so it is retained while any owner remains enabled. +/// This is intentionally not a generic "delete these symbols" override; it +/// fills in semantic ownership that cannot be recovered safely from QuickJS / +/// rquickjs callback tables, vtables, WIT resource drops, and component adapter +/// glue alone. const CAPABILITY_ROOTS_SECTION: &str = "wasm-rquickjs.capability-roots"; const CAPABILITY_ROOTS_MAGIC: &[u8; 8] = b"WRQJSCAP"; const CAPABILITY_ROOTS_VERSION: u8 = 1; @@ -498,13 +509,8 @@ fn build_capability_roots_metadata(module: &[u8]) -> Vec { let mut entries = BTreeSet::new(); for func in direct.keys().copied() { - for cap in reachable_builtin_capabilities( - func, - &names, - &helper_exports, - &direct, - &mut memo, - ) { + for cap in reachable_builtin_capabilities(func, &names, &helper_exports, &direct, &mut memo) + { entries.insert(CapabilityRootEntry { func, capability: cap, @@ -512,11 +518,11 @@ fn build_capability_roots_metadata(module: &[u8]) -> Vec { }); } if let Some(name) = names.get(&func) - && is_fs_loader_function(name) + && is_file_module_loader_function(name) { entries.insert(CapabilityRootEntry { func, - capability: Capability::Fs, + capability: Capability::FsModuleLoader, kind: ROOT_KIND_DIRECT_SCRUB, }); } @@ -564,10 +570,9 @@ fn function_names(module: &[u8]) -> BTreeMap { if section.name() != "name" { continue; } - let names = wasmparser_encoder::NameSectionReader::new(wasmparser_encoder::BinaryReader::new( - section.data(), - 0, - )); + let names = wasmparser_encoder::NameSectionReader::new( + wasmparser_encoder::BinaryReader::new(section.data(), 0), + ); for name in names.into_iter().flatten() { if let wasmparser_encoder::Name::Function(map) = name { for naming in map.into_iter().flatten() { @@ -656,14 +661,7 @@ fn reachable_builtin_capabilities( memo: &mut BTreeMap>, ) -> BTreeSet { let mut visiting = BTreeSet::new(); - reachable_builtin_capabilities_inner( - func, - names, - helper_exports, - direct, - memo, - &mut visiting, - ) + reachable_builtin_capabilities_inner(func, names, helper_exports, direct, memo, &mut visiting) } fn reachable_builtin_capabilities_inner( @@ -682,10 +680,8 @@ fn reachable_builtin_capabilities_inner( } let mut caps = BTreeSet::new(); - if let Some(name) = names.get(&func) - && let Some(cap) = callback_capability(name) - { - caps.insert(cap); + if let Some(name) = names.get(&func) { + caps.extend(function_name_capabilities(name)); } if let Some(cap) = helper_exports.get(&func) { caps.insert(*cap); @@ -708,6 +704,17 @@ fn reachable_builtin_capabilities_inner( caps } +fn function_name_capabilities(name: &str) -> BTreeSet { + let mut caps = BTreeSet::new(); + + if let Some(cap) = callback_capability(name) { + caps.insert(cap); + } + + caps.extend(wit_import_capabilities(name)); + caps +} + fn callback_capability(name: &str) -> Option { let module = builtin_module_fragment(name)?; Some(match module { @@ -738,6 +745,108 @@ fn callback_capability(name: &str) -> Option { }) } +struct SymbolCapabilityFamily { + fragments: &'static [&'static str], + capabilities: &'static [Capability], +} + +const SYMBOL_CAPABILITY_FAMILIES: &[SymbolCapabilityFamily] = &[ + SymbolCapabilityFamily { + fragments: &[ + "wasi::http::", + "wasi..http..", + "wasi:http/", + "golem_wasi_http::", + "golem_wasi_http..", + ], + // `fetch` and `node:http` share the same wasi:http-backed native + // resource types. Suppress roots that only reach those imports only + // when both capabilities are disabled. + capabilities: &[Capability::NodeFetch, Capability::NodeHttp], + }, + SymbolCapabilityFamily { + fragments: &[ + "wasi::filesystem::", + "wasi..filesystem..", + "wasi:filesystem/", + "wasip2::imports::wasi::filesystem::", + "wasip2..imports..wasi..filesystem..", + "wasi10filesystem", + "__wasm_import_filesystem_", + ], + // Filesystem WIT/resource glue is shared by user-facing `node:fs` and + // the optional filesystem-backed module loader. Keep it whenever either + // owner remains enabled. + capabilities: &[Capability::Fs, Capability::FsModuleLoader], + }, + SymbolCapabilityFamily { + fragments: &[ + "wasi::sockets::tcp", + "wasi..sockets..tcp", + "wasi:sockets/tcp", + "wasip2::imports::wasi::sockets::tcp", + "wasip2..imports..wasi..sockets..tcp", + "wasi7sockets3tcp", + ], + capabilities: &[Capability::Net], + }, + SymbolCapabilityFamily { + fragments: &[ + "wasi::sockets::udp", + "wasi..sockets..udp", + "wasi:sockets/udp", + "wasip2::imports::wasi::sockets::udp", + "wasip2..imports..wasi..sockets..udp", + "wasi7sockets3udp", + ], + capabilities: &[Capability::Dgram], + }, + SymbolCapabilityFamily { + fragments: &[ + "wasi::sockets::network", + "wasi..sockets..network", + "wasi:sockets/network", + "wasi::sockets::instance_network", + "wasi..sockets..instance_network", + "wasi:sockets/instance-network", + "wasip2::imports::wasi::sockets::network", + "wasip2..imports..wasi..sockets..network", + "wasip2::imports::wasi::sockets::instance_network", + "wasip2..imports..wasi..sockets..instance_network", + "wasi7sockets7network", + "wasi7sockets16instance_network", + ], + capabilities: &[Capability::Dgram, Capability::Dns, Capability::Net], + }, + SymbolCapabilityFamily { + fragments: &[ + "wasi::sockets::ip_name_lookup", + "wasi..sockets..ip_name_lookup", + "wasi:sockets/ip-name-lookup", + "wasip2::imports::wasi::sockets::ip_name_lookup", + "wasip2..imports..wasi..sockets..ip_name_lookup", + "wasi7sockets14ip_name_lookup", + ], + capabilities: &[Capability::Dns], + }, +]; + +fn wit_import_capabilities(name: &str) -> impl Iterator { + let mut caps = BTreeSet::new(); + + for family in SYMBOL_CAPABILITY_FAMILIES { + if contains_any(name, family.fragments) { + caps.extend(family.capabilities.iter().copied()); + } + } + + caps.into_iter() +} + +fn contains_any(haystack: &str, needles: &[&str]) -> bool { + needles.iter().any(|needle| haystack.contains(needle)) +} + fn builtin_module_fragment(name: &str) -> Option<&str> { let rest = if let Some((_, rest)) = name.split_once("::builtin::") { rest @@ -753,19 +862,8 @@ fn builtin_module_fragment(name: &str) -> Option<&str> { .filter(|s| !s.is_empty()) } -fn is_fs_loader_function(name: &str) -> bool { +fn is_file_module_loader_function(name: &str) -> bool { [ - "__wasilibc_populate_preopens", - "std::fs::", - "std..fs..", - "std::sys::fs::", - "std..sys..fs..", - "std::os::wasi::fs::", - "std..os..wasi..fs..", - "wasip2::imports::wasi::filesystem::", - "wasip2..imports..wasi..filesystem..", - "wasi::filesystem::", - "wasi..filesystem..", "::internal::NodeModulesResolver::", "..internal..NodeModulesResolver..", "::internal::FileUrlResolver::", @@ -961,12 +1059,8 @@ impl ReencodeComponent for CapabilityGlobalLowerer { let outer = std::mem::take(&mut self.module); let capability_roots = build_capability_roots_metadata(data); let mut module = wasm_encoder::Module::new(); - let result = wasm_encoder::reencode::utils::parse_core_module( - self, - &mut module, - parser, - data, - ); + let result = + wasm_encoder::reencode::utils::parse_core_module(self, &mut module, parser, data); if result.is_ok() && !capability_roots.is_empty() { module.section(&wasm_encoder::CustomSection { name: Cow::Borrowed(CAPABILITY_ROOTS_SECTION), @@ -1277,6 +1371,168 @@ mod tests { assert!(!saw_helper_export); } + #[test] + fn test_wit_import_name_marks_shared_http_capabilities() { + let caps = function_name_capabilities( + "_ZN99_$LT$wasip2..proxy..wasi..http..types..OutgoingBody$u20$as$u20$wasip2..proxy.._rt..WasmResource$GT$4drop4drop17h6f713e54171a0b9aE", + ); + + assert!(caps.contains(&Capability::NodeFetch)); + assert!(caps.contains(&Capability::NodeHttp)); + } + + #[test] + fn test_wit_import_name_marks_socket_capabilities() { + let tcp = function_name_capabilities( + "_ZN6wasip27imports4wasi7sockets3tcp9TcpSocket8shutdown11wit_import117h5b0c15b842d805aaE", + ); + assert!(tcp.contains(&Capability::Net)); + + let udp = function_name_capabilities( + "_ZN6wasip27imports4wasi7sockets3udp9UdpSocket14address_family11wit_import017h44adbf7bd3630a11E", + ); + assert!(udp.contains(&Capability::Dgram)); + + let network = function_name_capabilities( + "_ZN6wasip27imports4wasi7sockets7network7Network11wit_import017h44adbf7bd3630a11E", + ); + assert!(network.contains(&Capability::Dgram)); + assert!(network.contains(&Capability::Dns)); + assert!(network.contains(&Capability::Net)); + } + + #[test] + fn test_wit_import_name_marks_filesystem_shims() { + let caps = function_name_capabilities( + "__wasm_import_filesystem_method_descriptor_read_via_stream", + ); + assert!(caps.contains(&Capability::Fs)); + assert!(caps.contains(&Capability::FsModuleLoader)); + } + + #[test] + fn test_capability_roots_metadata_emits_all_shared_filesystem_owners() { + let module = build_named_empty_func_module( + "__wasm_import_filesystem_method_descriptor_read_via_stream", + ); + + let roots = parse_capability_roots_metadata(&build_capability_roots_metadata(&module)); + + assert!(roots.contains(&CapabilityRootEntry { + func: 0, + capability: Capability::Fs, + kind: ROOT_KIND_INDIRECT_ANY, + })); + assert!(roots.contains(&CapabilityRootEntry { + func: 0, + capability: Capability::FsModuleLoader, + kind: ROOT_KIND_INDIRECT_ANY, + })); + } + + #[test] + fn test_generic_filesystem_support_is_not_module_loader_only() { + let module = + build_named_empty_func_module("std::sys::fs::unix::File::open::h0123456789abcdef"); + + assert!(build_capability_roots_metadata(&module).is_empty()); + } + + #[test] + fn test_capability_roots_metadata_emits_all_shared_network_owners() { + let module = build_named_empty_func_module( + "_ZN6wasip27imports4wasi7sockets7network7Network11wit_import017h44adbf7bd3630a11E", + ); + + let roots = parse_capability_roots_metadata(&build_capability_roots_metadata(&module)); + + for capability in [Capability::Dgram, Capability::Dns, Capability::Net] { + assert!(roots.contains(&CapabilityRootEntry { + func: 0, + capability, + kind: ROOT_KIND_INDIRECT_ANY, + })); + } + } + + #[test] + fn test_reachable_capability_flows_through_wit_import_callee() { + let mut names = BTreeMap::new(); + names.insert( + 0, + "_ZN99_$LT$wasip2..proxy..wasi..http..types..OutgoingBody$u20$as$u20$wasip2..proxy.._rt..WasmResource$GT$4drop4drop17h6f713e54171a0b9aE" + .to_string(), + ); + names.insert( + 1, + "_ZN13rquickjs_core5class3ffi6VTable14finalizer_impl17ha32912750bccd5b5E".to_string(), + ); + + let direct = BTreeMap::from([(1, vec![0])]); + let helper_exports = BTreeMap::new(); + let mut memo = BTreeMap::new(); + + let caps = reachable_builtin_capabilities(1, &names, &helper_exports, &direct, &mut memo); + + assert!(caps.contains(&Capability::NodeFetch)); + assert!(caps.contains(&Capability::NodeHttp)); + } + + fn build_named_empty_func_module(function_name: &str) -> Vec { + let mut module = wasm_encoder::Module::new(); + + let mut types = wasm_encoder::TypeSection::new(); + types.ty().function([], []); + module.section(&types); + + let mut functions = wasm_encoder::FunctionSection::new(); + functions.function(0); + module.section(&functions); + + let mut code = wasm_encoder::CodeSection::new(); + let mut function = wasm_encoder::Function::new([]); + function.instruction(&wasm_encoder::Instruction::End); + code.function(&function); + module.section(&code); + + let mut names = wasm_encoder::NameMap::new(); + names.append(0, function_name); + let mut name_section = wasm_encoder::NameSection::new(); + name_section.functions(&names); + module.section(&name_section); + + module.finish() + } + + fn parse_capability_roots_metadata(data: &[u8]) -> BTreeSet { + assert!(data.starts_with(CAPABILITY_ROOTS_MAGIC)); + assert_eq!(data[CAPABILITY_ROOTS_MAGIC.len()], CAPABILITY_ROOTS_VERSION); + + let count_offset = CAPABILITY_ROOTS_MAGIC.len() + 1; + let count = u32::from_le_bytes(data[count_offset..count_offset + 4].try_into().unwrap()); + + let mut roots = BTreeSet::new(); + let mut offset = count_offset + 4; + for _ in 0..count { + let func = u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap()); + let capability = ALL_CAPABILITIES + .iter() + .copied() + .find(|capability| capability.bit_index() == data[offset + 4]) + .expect("unknown capability bit"); + let kind = data[offset + 5]; + roots.insert(CapabilityRootEntry { + func, + capability, + kind, + }); + offset += 6; + } + assert_eq!(offset, data.len()); + + roots + } + #[test] fn test_patch_capability_gates_no_marker() { let buf = vec![0u8; 100]; @@ -1308,10 +1564,10 @@ mod tests { (1u64 << Capability::Fs.bit_index()) | (1u64 << Capability::Sqlite.bit_index()) ); - // The enum is laid out so all 52 bits fit inside the lower half of u64. + // The enum is laid out so all known bits fit inside the lower half of u64. let all_known: u64 = enabled_bits(crate::capability_scan::ALL_CAPABILITIES.iter().copied()); - assert_eq!(all_known.count_ones(), 52); - // Bits 52..64 must be unused. - assert_eq!(all_known & !((1u64 << 52) - 1), 0); + assert_eq!(all_known.count_ones(), 53); + // Bits 53..64 must be unused. + assert_eq!(all_known & !((1u64 << 53) - 1), 0); } } diff --git a/src/main.rs b/src/main.rs index f0f429f00..69bbe1972 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use crate::cli::{Args, Command}; use anyhow::Context; -use clap::Parser; use camino::Utf8Path; +use clap::Parser; use std::collections::{BTreeMap, BTreeSet}; use wasm_rquickjs::capability_scan::ALL_CAPABILITIES; use wasm_rquickjs::{ @@ -61,7 +61,9 @@ fn component_dce_options_from_ir( for (component_idx, component) in ir.nested_components.iter().enumerate() { let child = component_dce_options_from_ir(component, enabled_bits)?; if child != wasm_eliminator::component::DceOptions::default() { - options.nested_components.insert(component_idx as u32, child); + options + .nested_components + .insert(component_idx as u32, child); } } @@ -98,7 +100,8 @@ fn producer_hints_from_capability_roots( fn capability_foldable_globals( module: &[u8], enabled_bits: u64, -) -> anyhow::Result> { +) -> anyhow::Result> +{ let mut imported_globals = 0u32; let mut defined_i32_consts = Vec::new(); @@ -154,7 +157,9 @@ fn capability_foldable_globals( .collect()) } -fn imported_global_count_for_imports(imports: wasmparser_encoder::Imports<'_>) -> anyhow::Result { +fn imported_global_count_for_imports( + imports: wasmparser_encoder::Imports<'_>, +) -> anyhow::Result { Ok(match imports { wasmparser_encoder::Imports::Single(_, import) => { imported_global_count_for_type_ref(import.ty, 1) @@ -215,12 +220,13 @@ fn apply_capability_roots_section( match kind { ROOT_KIND_INDIRECT_ANY | ROOT_KIND_DIRECT_SCRUB => {} - other => anyhow::bail!( - "unsupported {CAPABILITY_ROOTS_SECTION} root kind {other}" - ), + other => anyhow::bail!("unsupported {CAPABILITY_ROOTS_SECTION} root kind {other}"), } - roots.entry((func, kind)).or_default().insert(capability_bit); + roots + .entry((func, kind)) + .or_default() + .insert(capability_bit); } for ((func, kind), capability_bits) in roots { @@ -463,8 +469,8 @@ fn main() { if want_patch { use std::collections::BTreeSet; use wasm_rquickjs::capability_scan::{ - ALL_CAPABILITIES, Capability, Policy, ScanResult, apply_policy, - enabled_bits, scan_entry_point, + ALL_CAPABILITIES, Capability, Policy, ScanResult, apply_policy, enabled_bits, + scan_entry_point, }; // Helper to parse capability marker names from `--include`/`--exclude` @@ -541,9 +547,7 @@ fn main() { // Stage to the output path, then patch + inject in two steps so // the user gets a single output even when both happen. let staging = output.with_extension("wasm.staging"); - if let Err(err) = - wasm_rquickjs::patch_capability_gates(input, &staging, bits) - { + if let Err(err) = wasm_rquickjs::patch_capability_gates(input, &staging, bits) { eprintln!("Error patching capability gates: {err:#}"); std::process::exit(1); } diff --git a/tests/binary_inject.rs b/tests/binary_inject.rs index 5229b75c5..1c0f7f8cc 100644 --- a/tests/binary_inject.rs +++ b/tests/binary_inject.rs @@ -8,11 +8,11 @@ use camino::{Utf8Path, Utf8PathBuf}; use common::TestInstance; use heck::ToSnakeCase; use std::process::Command; +use wasm_rquickjs::capability_scan::{ALL_CAPABILITIES, Capability, enabled_bits}; use wasm_rquickjs::{ EmbeddingMode, JsModuleSpec, generate_wrapper_crate, inject_js_into_component, patch_capability_gates_in_bytes, read_capability_gates_from_bytes, }; -use wasm_rquickjs::capability_scan::{ALL_CAPABILITIES, Capability, enabled_bits}; use wasmtime::component::Val; /// Generates a wrapper crate using BinarySlot mode, compiles it, injects JS, @@ -266,8 +266,8 @@ async fn test_patch_capability_gates_and_run() { let builder = BinarySlotTestBuilder::new("example1").expect("Failed to build template"); // Step 1 + 2: read the default gates from the freshly-built template. - let template_bytes = std::fs::read(builder.wasm_path.as_std_path()) - .expect("Failed to read template wasm"); + let template_bytes = + std::fs::read(builder.wasm_path.as_std_path()).expect("Failed to read template wasm"); let initial = read_capability_gates_from_bytes(&template_bytes) .expect("Failed to read default capability gates from template"); assert_eq!( @@ -308,8 +308,8 @@ async fn test_patch_capability_gates_and_run() { .expect("patch_capability_gates_in_bytes failed"); // Step 4: read back and verify each disabled bit is cleared. - let read_back = read_capability_gates_from_bytes(&patched_bytes) - .expect("Failed to read patched gates"); + let read_back = + read_capability_gates_from_bytes(&patched_bytes).expect("Failed to read patched gates"); assert_eq!(read_back, new_bits, "patched gates must round-trip"); for cap in to_disable { assert_eq!( diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 06098961e..3cfd65a93 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -17,11 +17,11 @@ use std::sync::{Arc, Mutex, OnceLock}; use std::thread; use std::time::{Duration, Instant, SystemTime}; use tokio::time::timeout; +use wac_graph::types::{Package, SubtypeChecker}; +use wac_graph::{CompositionGraph, EncodeOptions, PackageId, PlugError}; use wasm_rquickjs::capability_scan::{ ALL_CAPABILITIES, Policy, ScanResult, apply_policy, enabled_bits, scan_entry_point, }; -use wac_graph::types::{Package, SubtypeChecker}; -use wac_graph::{CompositionGraph, EncodeOptions, PackageId, PlugError}; use wasm_rquickjs::{ EmbeddingMode, GenerationTarget, JsModuleSpec, generate_wrapper_crate_with_target, patch_capability_gates_in_bytes, @@ -2530,7 +2530,9 @@ fn env_flag(name: &str) -> bool { } fn capability_bits_for_example(example_path: &Utf8Path, trim_unknown: bool) -> anyhow::Result { - let name = example_path.file_name().expect("example path has a file name"); + let name = example_path + .file_name() + .expect("example path has a file name"); let js_path = example_path.join("src").join(format!("{name}.js")); let scan = if js_path.exists() { scan_entry_point(&js_path) @@ -2554,7 +2556,11 @@ fn capability_bits_for_example(example_path: &Utf8Path, trim_unknown: bool) -> a Ok(enabled_bits(outcome.enabled.iter().copied())) } -fn auto_trim_component(input: &Utf8Path, output: &Utf8Path, enabled_bits: u64) -> anyhow::Result<()> { +fn auto_trim_component( + input: &Utf8Path, + output: &Utf8Path, + enabled_bits: u64, +) -> anyhow::Result<()> { let before = fs::read(input.as_std_path())?; let patched = patch_capability_gates_in_bytes(&before, enabled_bits)?; let component_options = component_dce_options_from_capability_roots(&patched, enabled_bits)?; @@ -2564,7 +2570,11 @@ fn auto_trim_component(input: &Utf8Path, output: &Utf8Path, enabled_bits: u64) - }; let after = wasm_eliminator::dce_with_options(&patched, &options)?; fs::write(output.as_std_path(), &after)?; - println!("wasm-eliminator auto-trim: {} B -> {} B", before.len(), after.len()); + println!( + "wasm-eliminator auto-trim: {} B -> {} B", + before.len(), + after.len() + ); Ok(()) } @@ -2591,7 +2601,9 @@ fn component_dce_options_from_ir( for (component_idx, component) in ir.nested_components.iter().enumerate() { let child = component_dce_options_from_ir(component, enabled_bits)?; if child != wasm_eliminator::component::DceOptions::default() { - options.nested_components.insert(component_idx as u32, child); + options + .nested_components + .insert(component_idx as u32, child); } } @@ -2684,7 +2696,9 @@ fn capability_foldable_globals( .collect()) } -fn imported_global_count_for_imports(imports: wasmparser_encoder::Imports<'_>) -> anyhow::Result { +fn imported_global_count_for_imports( + imports: wasmparser_encoder::Imports<'_>, +) -> anyhow::Result { Ok(match imports { wasmparser_encoder::Imports::Single(_, import) => { imported_global_count_for_type_ref(import.ty, 1) @@ -2745,12 +2759,13 @@ fn apply_capability_roots_section( match kind { ROOT_KIND_INDIRECT_ANY | ROOT_KIND_DIRECT_SCRUB => {} - other => anyhow::bail!( - "unsupported {CAPABILITY_ROOTS_SECTION} root kind {other}" - ), + other => anyhow::bail!("unsupported {CAPABILITY_ROOTS_SECTION} root kind {other}"), } - roots.entry((func, kind)).or_default().insert(capability_bit); + roots + .entry((func, kind)) + .or_default() + .insert(capability_bit); } for ((func, kind), capability_bits) in roots {