diff --git a/.github/workflows/host-checks.yml b/.github/workflows/host-checks.yml index 5e6e2be..8b64b78 100644 --- a/.github/workflows/host-checks.yml +++ b/.github/workflows/host-checks.yml @@ -60,6 +60,9 @@ jobs: - name: Cargo clippy (warnings are errors) run: cargo clippy --all-targets --locked -- -D warnings + - name: Cargo clippy with Wasm host tools + run: cargo clippy --bin hyperlight-unikraft --tests --locked --features wasm-host-fns -- -D warnings + - name: Enable KVM permissions working-directory: . run: | @@ -75,6 +78,11 @@ jobs: RUST_BACKTRACE: '1' run: cargo test --all-targets --locked + - name: Cargo test with Wasm host tools + env: + RUST_BACKTRACE: '1' + run: cargo test --bin hyperlight-unikraft --locked --features wasm-host-fns wasm_host_fns::tests + host-checks-passed: if: always() needs: [checks] diff --git a/README.md b/README.md index 2a4b0e9..49d41e4 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ This project enables running Linux applications (Python, Node.js, Go, Rust, C/C+ ### Key Features -- **Thin, opt-in host surface** — by default, the guest has no access to the host filesystem, network, or any host functions. When you enable features like `--mount`, `--net`, or `--enable-tools`, a single `__dispatch` JSON-RPC bridge is registered as the only guest→host channel. See [HOST_FUNCTIONS.md](HOST_FUNCTIONS.md) for the full list of dispatchable operations +- **Thin, opt-in host surface** — by default, the guest has no access to the host filesystem, network, or any host functions. When you enable features like `--mount`, `--net`, `--enable-tools`, or `--tool`, a single `__dispatch` JSON-RPC bridge is registered as the only guest→host channel. See [host_functions.md](docs/host_functions.md) for the full list of dispatchable operations - **Identity-mapped memory** - Simplified memory layout (vaddr == paddr) - **Generic cmdline mechanism** - Pass arguments to any application via `-- arg1 arg2 ...` - **Fast cold start** - Hyperlight's lightweight design enables millisecond startup times @@ -281,7 +281,19 @@ Options: -m, --memory Memory allocation (e.g., 256Mi, 512Mi, 1Gi) [default: 512Mi] --stack Stack size (e.g., 8Mi) [default: 8Mi] -q, --quiet Quiet mode — suppress host-side status messages - --enable-tools Enable tool dispatch via __dispatch host function + --enable-tools Register the built-in echo tool + --tool Register a WASIp1 module as a host tool (requires wasm-host-fns) + --tool-wasi-dir + Preopen a read-write host directory for Wasm tools + --tool-wasi-dir-ro + Preopen a read-only host directory for Wasm tools + --tool-wasi-env + Set an environment variable for Wasm tools + --tool-wasi-env-inherit + Inherit one host environment variable into Wasm tools + --tool-wasi-fuel Fuel units available to each Wasm tool call [default: 100000000] + --tool-wasi-output-limit + Maximum stdout or stderr captured from one Wasm tool call [default: 1Mi] --mount Preopen a host directory for the guest's sandboxed filesystem (repeatable; default guest path: /host) --net Enable guest networking (off by default) @@ -297,6 +309,17 @@ Options: -V, --version Print version ``` +### Wasm host tools + +Build the CLI with the optional `wasm-host-fns` feature to register host-side custom tools from WASIp1 modules: + +```bash +cargo build --manifest-path host/Cargo.toml --release --features wasm-host-fns --bin hyperlight-unikraft +hyperlight-unikraft kernel --initrd app.cpio --tool greet=./greet.wasm +``` + +The guest still calls the existing `__dispatch` envelope, for example `{"name":"greet","args":{"who":"Ada"}}`. The Wasm handler runs on the host in Wasmtime, receives that request JSON on stdin, and writes either a raw JSON result or `{"result": ...}` / `{"error": "..."}` to stdout. WASI filesystem and environment access are off unless granted with `--tool-wasi-dir`, `--tool-wasi-dir-ro`, `--tool-wasi-env`, or `--tool-wasi-env-inherit`. + ## Project Structure ``` diff --git a/docs/host_functions.md b/docs/host_functions.md index 3c5b1e3..b1aec6a 100644 --- a/docs/host_functions.md +++ b/docs/host_functions.md @@ -15,7 +15,7 @@ Implementation lives in [`host/src/lib.rs`](../host/src/lib.rs). Guest-side call | `fs_*` tools | **Off** | `--mount HOST[:GUEST]` (repeatable) | | `net_*` tools | **Off** | `--net`, `--net-allow`, or `--net-block` | | Inbound listen | **Off** | `--port PORT` (requires network enabled) | -| Custom tools | **Off** | `--enable-tools` + `SandboxBuilder::tool()` | +| Custom tools | **Off** | `--tool NAME=WASM` with `wasm-host-fns`, `SandboxBuilder::tool()`, or legacy/demo `--enable-tools` echo | With **no flags**, the guest cannot reach the host filesystem or network through dispatch. Only internal plumbing (`__hl_exit`, `__hl_sleep`) is wired. @@ -85,7 +85,18 @@ hyperlight-unikraft KERNEL [--initrd CPIO] [options] [-- APP_ARGS...] | `--net-allow HOST_OR_IP` | Allow-list outbound destinations (implies `--net`). Repeatable. | | `--net-block HOST_OR_IP` | Block-list; all other destinations allowed (implies `--net`). Mutually exclusive with `--net-allow`. | | `--port PORT` | Allow `net_bind` / listen on `PORT` (implies `--net`). Without `--port`, outbound-only: bind is rejected. | -| `--enable-tools` | Enables custom tool registration. Registers a built-in `echo` tool (used by the `python-tools` example). Library users add their own tools via `SandboxBuilder::tool()`. | +| `--enable-tools` | Registers only the built-in `echo` demo tool. It does not load user code; prefer `--tool NAME=WASM` for CLI custom tools or `SandboxBuilder::tool()` for library users. | +| `--tool NAME=WASM` | With the Cargo feature `wasm-host-fns`, registers `WASM` as a host-side WASIp1 custom tool named `NAME`. Repeatable. | +| `--tool-wasi-dir HOST[:GUEST]` | Preopens a read-write host directory for every CLI Wasm tool. Default guest path is `/host`. Repeatable. | +| `--tool-wasi-dir-ro HOST[:GUEST]` | Preopens a read-only host directory for every CLI Wasm tool. Default guest path is `/host`. Repeatable. | +| `--tool-wasi-env KEY=VALUE` | Sets an environment variable for every CLI Wasm tool. Repeatable. | +| `--tool-wasi-env-inherit KEY` | Copies one host environment variable into every CLI Wasm tool. Repeatable. | +| `--tool-wasi-fuel FUEL` | Sets the instruction-fuel budget for each call to every CLI Wasm tool. Default `100000000`. | +| `--tool-wasi-output-limit SIZE` | Caps captured stdout and stderr for each call to every CLI Wasm tool. Default `1Mi`. | + +`--tool-wasi-*` flags configure the Wasmtime/WASI sandbox for Wasm custom tools only. They do not expose the guest `--mount` filesystem, and they do not change the `fs_*` handlers used by `lib/hostfs`. + +The CLI currently applies the same Wasm filesystem, environment, fuel, and output settings to every `--tool` registered in one invocation. If tools need different permissions or limits, do not grant the union to all handlers; that requires a narrower per-tool configuration surface or a separate host integration. **Mount rules (host-enforced before boot):** @@ -189,7 +200,38 @@ Sockets are host-side (`socket2`); the guest sees opaque numeric **`fd`** handle ## Custom tools -**CLI:** `--enable-tools` registers a built-in `echo` tool (returns `args` unchanged) used by the [`python-tools` example](../examples/python-tools). The primary purpose of `--enable-tools` is to demonstrate custom host function registration via the API. +**CLI demo tool:** `--enable-tools` registers only a built-in `echo` tool that returns `args` unchanged. It is useful as a smoke test and compatibility path, but it is not the CLI extension mechanism for user-provided host functions. CLI examples should prefer a Wasm `echo.wasm` registered with `--tool echo=...`; library examples should register an echo handler with `SandboxBuilder::tool()`. + +**CLI Wasm tools:** build with the optional feature and pass one or more `--tool` flags: + +```bash +cargo build --manifest-path host/Cargo.toml --features wasm-host-fns --bin hyperlight-unikraft +hyperlight-unikraft kernel --initrd app.cpio --tool greet=./greet.wasm +``` + +Each `--tool NAME=WASM` module is compiled and linked before VM boot, then invoked as a fresh WASIp1 command for every matching guest `__dispatch` call. The handler receives the existing dispatch request on stdin: + +```json +{"name":"NAME","args":} +``` + +The handler writes JSON to stdout. It may write either a raw JSON result value or the normal dispatch envelope: + +```json +{"result":} +``` + +```json +{"error":"message"} +``` + +A raw value is treated as the tool result. A single-key `result` envelope is unwrapped. A single-key `error` envelope becomes the outer `__dispatch` error response. Empty stdout returns JSON null. + +Wasm tools are separate from the built-in `fs_*` and `net_*` dispatch handlers. `--mount` controls what the guest can access through `lib/hostfs`; `--tool-wasi-dir*` controls what the host-side Wasm handler can access through its own WASI filesystem view. + +WASI capabilities are denied by default except stdio used for the protocol, clocks, and random. Use `--tool-wasi-dir`, `--tool-wasi-dir-ro`, `--tool-wasi-env`, and `--tool-wasi-env-inherit` to grant explicit filesystem and environment access to handlers. These grants and the `--tool-wasi-fuel` / `--tool-wasi-output-limit` settings apply to every CLI Wasm tool registered by the process. Tool names beginning with `__`, `fs_`, or `net_` are reserved. + +**Why WASIp1 command modules today?** The current CLI maps one `--tool NAME=WASM` flag to one tool name and one fresh handler invocation. WASIp1 keeps that ABI small: JSON request on stdin, JSON response on stdout, no long-lived reactor state, and broad language/toolchain support. Component-model or reactor-style handlers could support a future `--tools component.wasm` shape with multiple exported tools and auto-registration, but that would need a separate registration and lifecycle model; it is not the current ABI. **Library:** @@ -199,7 +241,7 @@ Sandbox::builder("kernel") .build()?; ``` -Custom handlers run with the same JSON request/response envelope as built-in tools. +`SandboxBuilder::tool()` handlers receive the inner `args` JSON value from the dispatch request; the registry has already matched the outer `name`. Handler return values become the `result` field in the outer `__dispatch` response, and handler errors become `{"error": "..."}`. --- @@ -214,6 +256,8 @@ Custom handlers run with the same JSON request/response envelope as built-in too | `fs_list` entries | 100 000 | | `net_send` / `net_sendto` | 1 MiB decoded bytes | | `__hl_sleep` | 60 s | +| Wasm tool fuel | 100 000 000 instructions per call by default; configurable with `--tool-wasi-fuel`; same value applies to every CLI Wasm tool | +| Wasm tool stdout / stderr | 1 MiB each per call by default; configurable with `--tool-wasi-output-limit`; same value applies to every CLI Wasm tool | | Open host sockets | 1024 per sandbox | | AllowList learned DNS IPs | 256 | @@ -242,6 +286,14 @@ Custom handlers run with the same JSON request/response envelope as built-in too - A compromised guest can invoke any **registered** tool name; do not register powerful custom tools unless needed. - Payload size is capped; malformed JSON fails closed with an error response. +**When `--tool` is used with `wasm-host-fns`:** + +- Handler code runs on the host inside Wasmtime, not inside the Unikraft VM. +- WASI filesystem and environment access are capability-based and off unless explicitly granted with `--tool-wasi-*` flags. +- CLI Wasm capability and limit flags apply to every registered Wasm tool; avoid combining handlers with different privilege needs in one invocation. +- Fuel limits bound Wasm instruction execution, but do not turn filesystem operations into a full wall-clock timeout. +- Handlers are untrusted code from the host operator's filesystem; only load modules you intend to grant these capabilities to. + **Not exposed via dispatch:** Host shell, arbitrary process spawn, unrestricted host `exec`, or kernel modules — only the tools listed above. **Operators should:** Use minimal flags, allow-lists over `--net` where possible, mount least-privilege directories, and run guests with the smallest initrd/runtime required. diff --git a/host/Cargo.lock b/host/Cargo.lock index 4ec277d..f09ff69 100644 --- a/host/Cargo.lock +++ b/host/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -17,6 +26,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -82,6 +103,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + [[package]] name = "arrayref" version = "0.3.9" @@ -94,6 +121,17 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -166,6 +204,9 @@ name = "bumpalo" version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +dependencies = [ + "allocator-api2", +] [[package]] name = "bytemuck" @@ -193,6 +234,84 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cap-fs-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes", + "windows-sys 0.52.0", +] + +[[package]] +name = "cap-net-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7" +dependencies = [ + "cap-primitives", + "cap-std", + "rustix 1.1.4", + "smallvec", +] + +[[package]] +name = "cap-primitives" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes", + "ipnet", + "maybe-owned", + "rustix 1.1.4", + "rustix-linux-procfs", + "windows-sys 0.52.0", + "winx", +] + +[[package]] +name = "cap-rand" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d" +dependencies = [ + "ambient-authority", + "rand 0.8.6", +] + +[[package]] +name = "cap-std" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes", + "rustix 1.1.4", +] + +[[package]] +name = "cap-time-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80" +dependencies = [ + "ambient-authority", + "cap-primitives", + "iana-time-zone", + "once_cell", + "rustix 1.1.4", + "winx", +] + [[package]] name = "cc" version = "1.2.53" @@ -225,7 +344,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.0", ] [[package]] @@ -279,6 +398,15 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -315,6 +443,144 @@ dependencies = [ "libc", ] +[[package]] +name = "cranelift-assembler-x64" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de2be1bdbf929c2a2242cbbe15d6583c56f1cc723c6c8452d0179362de28c9d5" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a0336914de11298290783a95a9a7154b894da601659eb5f8f8bc62d1bea98f8" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb972cba51a52c1b2a329fec993b911e4d1f9cfab3795811a319b6746c28e014" +dependencies = [ + "cranelift-entity", +] + +[[package]] +name = "cranelift-bitset" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "642c920666bfed9aebca39d8c6e7cb76f09314cc7a4074b1db5edcccdde771b9" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-codegen" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1231caaeee3d2363d9b2dba9d6c1f7ff835b8ede6612fba98120af73df44bd" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.15.5", + "log", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon", + "wasmtime-internal-math", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb83e89be8b413e4f7a4215a02d5c5f3e6f04b1060f5db293dd1007b2871dcf5" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d14f8068a98f0a85ffa63dc5fe73cb486a955adbe7311465d13cde54c656d5f" + +[[package]] +name = "cranelift-control" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c070aee9312b9736028e99b58d45e1099683386082af38529d5e2ce8c76648f3" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d619bb3d14251e96dc9b6a846d6955d78048a168cc3876eb2b789b855c1c22" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-frontend" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2350fcff24d78be5e4201e1eeb4b306e474b9f21e452722b21ffc4f773e8d49a" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bdc2b14d7491c53c2989b967b4c07511374733abbc01a895fb01ea31e97bfc8" + +[[package]] +name = "cranelift-native" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e98dbe1326d0001a17b3b0675e3adafcfbd0e7f25f1f845a2f1bb9ce3029f359" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d7af563cd300c8a1e4e64387929b40e32867112143f0a0e1ce90f977ce4a41" + [[package]] name = "crc32fast" version = "1.5.0" @@ -391,6 +657,33 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -407,6 +700,29 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.52.0", +] + [[package]] name = "filetime" version = "0.2.29" @@ -458,6 +774,77 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes", + "rustix 1.1.4", + "windows-sys 0.52.0", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -500,11 +887,22 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.0", "wasip2", "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 = "git2" version = "0.20.4" @@ -549,6 +947,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash", + "serde", ] [[package]] @@ -588,7 +987,7 @@ dependencies = [ "flatbuffers", "log", "spin", - "thiserror", + "thiserror 2.0.18", "tracing", "tracing-core", ] @@ -618,11 +1017,11 @@ dependencies = [ "mshv-bindings", "mshv-ioctls", "page_size", - "rand", + "rand 0.10.1", "rust-embed", "serde_json", "termcolor", - "thiserror", + "thiserror 2.0.18", "tracing", "tracing-core", "tracing-log", @@ -647,9 +1046,12 @@ dependencies = [ "memmap2", "nix", "serde_json", - "socket2", + "socket2 0.5.10", "tar", + "tempfile", "ureq", + "wasmtime", + "wasmtime-wasi", "windows-sys 0.61.2", ] @@ -797,12 +1199,43 @@ dependencies = [ "serde_core", ] +[[package]] +name = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes", + "windows-sys 0.52.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -856,6 +1289,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cc46bac87ef8093eed6f272babb833b6443374399985ac8ed28471ee0918545" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -880,6 +1319,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.12" @@ -902,6 +1347,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -930,22 +1381,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "memmap2" -version = "0.9.9" +name = "mach2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" dependencies = [ "libc", ] [[package]] -name = "metrics" +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix 1.1.4", +] + +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + +[[package]] +name = "metrics" version = "0.24.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff56c2e7dce6bd462e3b8919986a617027481b1dcc703175b58cf9dd98a2f071" @@ -964,6 +1439,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "mshv-bindings" version = "0.6.6" @@ -984,7 +1470,7 @@ checksum = "77e058608d09f2f8b106b06e6c58a09aa44915dd6a36cd4142d3a7d32e59c1fb" dependencies = [ "libc", "mshv-bindings", - "thiserror", + "thiserror 2.0.18", "vmm-sys-util", ] @@ -1030,6 +1516,18 @@ dependencies = [ "syn", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "crc32fast", + "hashbrown 0.15.5", + "indexmap", + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1088,6 +1586,18 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1097,6 +1607,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1116,6 +1635,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulley-interpreter" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "329f575a931601f71fbcb3b31d32d16273da5ba7f532fc10be2e432e710b02de" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-math", +] + +[[package]] +name = "pulley-macros" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bccae89ed67a40989e780105fab43e6c71a077b9fc8ae4c805ff5f73d2a79c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "quote" version = "1.0.43" @@ -1137,6 +1679,17 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.1" @@ -1145,7 +1698,26 @@ checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.2", - "rand_core", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1171,7 +1743,21 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.18", +] + +[[package]] +name = "regalloc2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5216b1837de2149f8bc8e6d5f88a9326b63b8c836ed58ce4a0a29ec736a59734" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.15.5", + "log", + "rustc-hash", + "smallvec", ] [[package]] @@ -1241,6 +1827,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1250,6 +1842,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1259,10 +1864,20 @@ dependencies = [ "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.1.4", +] + [[package]] name = "rustls" version = "0.23.40" @@ -1344,6 +1959,10 @@ name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -1352,6 +1971,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -1424,6 +2044,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -1435,6 +2058,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spin" version = "0.10.0" @@ -1484,6 +2117,22 @@ dependencies = [ "syn", ] +[[package]] +name = "system-interface" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" +dependencies = [ + "bitflags 2.11.1", + "cap-fs-ext", + "cap-std", + "fd-lock", + "io-lifetimes", + "rustix 0.38.44", + "windows-sys 0.52.0", + "winx", +] + [[package]] name = "tar" version = "0.4.45" @@ -1495,6 +2144,25 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -1504,13 +2172,33 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1534,6 +2222,20 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2 0.6.4", + "windows-sys 0.61.2", +] + [[package]] name = "tracing" version = "0.1.44" @@ -1779,6 +2481,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.236.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "724fccfd4f3c24b7e589d333fc0429c68042897a7e8a5f8694f31792471841e7" +dependencies = [ + "leb128fmt", + "wasmparser 0.236.1", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -1786,7 +2498,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.244.0", ] [[package]] @@ -1797,8 +2509,21 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap", - "wasm-encoder", - "wasmparser", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasmparser" +version = "0.236.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", + "serde", ] [[package]] @@ -1813,6 +2538,306 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmprinter" +version = "0.236.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2df225df06a6df15b46e3f73ca066ff92c2e023670969f7d50ce7d5e695abbb1" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.236.1", +] + +[[package]] +name = "wasmtime" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507d213104e83a7519d91af444a8b19c04281f2eef162d448ee7a894ac1c827d" +dependencies = [ + "addr2line", + "anyhow", + "async-trait", + "bitflags 2.11.1", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "hashbrown 0.15.5", + "indexmap", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "postcard", + "pulley-interpreter", + "rustix 1.1.4", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasmparser 0.236.1", + "wasmtime-environ", + "wasmtime-internal-asm-macros", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-math", + "wasmtime-internal-slab", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wasmtime-internal-winch", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-environ" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9784b325c3b85562ac6d7f81c8348c42af1f137d98dd4fc6631860e4e68bb655" +dependencies = [ + "anyhow", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "indexmap", + "log", + "object", + "postcard", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasm-encoder 0.236.1", + "wasmparser 0.236.1", + "wasmprinter", + "wasmtime-internal-component-util", +] + +[[package]] +name = "wasmtime-internal-asm-macros" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcaa9336cd5ba934ba734dfdfe35f5245c3c74b4e34f9af9e114fad892d81b3d" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91aba228ec4f646cb9514be55538c842822cb96f2c306f75d664eea6b6f2e9eb" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser 0.236.1", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f4a2387b0aea544aa2317e295583be54fed852e0d1a31c0070984bfd6a507" + +[[package]] +name = "wasmtime-internal-cranelift" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7d938ae501275f44e7e5532ae4bb720542b429357014d33842e128c46fb9b54" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools", + "log", + "object", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.236.1", + "wasmtime-environ", + "wasmtime-internal-math", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1443b0914ff848ee7920e0f232368168e2819b739c54f3c352f0559b6164343" +dependencies = [ + "anyhow", + "cc", + "cfg-if", + "libc", + "rustix 1.1.4", + "wasmtime-internal-asm-macros", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "861d6f2a1652e95ca10b02552934b3bd460d7416b285fe10d7ca8c0a2b90dc3e" +dependencies = [ + "cc", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1caeb3140c46319fecf09d93dc38a373eb535fd478e401a9fb2ac2da30fe5f6" +dependencies = [ + "anyhow", + "cfg-if", + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-internal-math" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c631615929951a4076aae64da7d6cad88668d292f19672606392c24ae9c5a00" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmtime-internal-slab" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b28104d57b5bdb5d8facb3a8418463ec6c2cb40bb4adf9833b727ebf6a254eb" + +[[package]] +name = "wasmtime-internal-unwinder" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd89f2db7377869aeaf66b71f56def8df54b9482e4f4e5533ccec2505f5c691" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "log", + "object", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9cdb9c2e3965ee15629d067203cb800e9822664d04335dadc6fe1788d4fc335" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasmtime-internal-winch" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53f693c8db710f20b927bcee025acd345acf599d055b63f122613d52f5553a5f" +dependencies = [ + "anyhow", + "cranelift-codegen", + "gimli", + "object", + "target-lexicon", + "wasmparser 0.236.1", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "winch-codegen", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6c97d4e849494d290e05573298bd372e12be86b2074502dc5e02f4ef7628002" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "heck", + "indexmap", + "wit-parser 0.236.1", +] + +[[package]] +name = "wasmtime-wasi" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eabc75a6afeac11870ee5402268ec0f61fc394728d6dcbe5091a57dc6eb5a57" +dependencies = [ + "anyhow", + "async-trait", + "bitflags 2.11.1", + "bytes", + "cap-fs-ext", + "cap-net-ext", + "cap-rand", + "cap-std", + "cap-time-ext", + "fs-set-times", + "futures", + "io-extras", + "io-lifetimes", + "rustix 1.1.4", + "system-interface", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasmtime", + "wasmtime-wasi-io", + "wiggle", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-wasi-io" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367b77d382241c7f9b3cde3c8cfc1c0d800f04d665622779a724b71d0a2a2028" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "futures", + "wasmtime", +] + +[[package]] +name = "wast" +version = "35.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" +dependencies = [ + "leb128", +] + [[package]] name = "webpki-roots" version = "1.0.7" @@ -1822,6 +2847,47 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "wiggle" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aed7ef247a05956b0a25e7905fdb709ae89e506547af42897e40301b0658d07" +dependencies = [ + "anyhow", + "async-trait", + "bitflags 2.11.1", + "thiserror 2.0.18", + "tracing", + "wasmtime", + "wiggle-macro", +] + +[[package]] +name = "wiggle-generate" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d5550c5f49730d0a8babd089771ff7412e598cf8f7bbe3b647b8e2147a11b" +dependencies = [ + "anyhow", + "heck", + "proc-macro2", + "quote", + "syn", + "witx", +] + +[[package]] +name = "wiggle-macro" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602175405f0c04fd47439ad6afc5e151c4864883259254d3676562bfb00a7ce8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wiggle-generate", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1853,6 +2919,26 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "winch-codegen" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4332c8656af179fb8fc3ae5114c738c29399ee97b638d431725201c17f99294e" +dependencies = [ + "anyhow", + "cranelift-assembler-x64", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.236.1", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "wasmtime-internal-math", +] + [[package]] name = "windows" version = "0.62.2" @@ -1960,7 +3046,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -1978,14 +3073,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -2012,48 +3124,106 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.11.1", + "windows-sys 0.52.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2071,7 +3241,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", - "wit-parser", + "wit-parser 0.244.0", ] [[package]] @@ -2118,10 +3288,28 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder", + "wasm-encoder 0.244.0", "wasm-metadata", - "wasmparser", - "wit-parser", + "wasmparser 0.244.0", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-parser" +version = "0.236.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e4833a20cd6e85d6abfea0e63a399472d6f88c6262957c17f546879a80ba15" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.236.1", ] [[package]] @@ -2139,7 +3327,19 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "witx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" +dependencies = [ + "anyhow", + "log", + "thiserror 1.0.69", + "wast", ] [[package]] @@ -2155,7 +3355,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] diff --git a/host/Cargo.toml b/host/Cargo.toml index 451da06..7d065b0 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -26,6 +26,10 @@ path = "src/bin/pydriver_run.rs" name = "pyhl" path = "src/bin/pyhl.rs" +[features] +default = [] +wasm-host-fns = ["dep:wasmtime", "dep:wasmtime-wasi"] + [dependencies] # danbugs/hyperlight perf/whp-warm-start — snapshot file support + WHP warm-start optimizations. hyperlight-host = { git = "https://github.com/danbugs/hyperlight", rev = "5cf37d92", features = ["executable_heap", "hw-interrupts", "whp-no-surrogate"] } @@ -38,6 +42,8 @@ socket2 = { version = "0.5", features = ["all"] } ureq = "3" flate2 = "1" tar = "0.4" +wasmtime = { version = "36.0.2", optional = true, default-features = false, features = ["cranelift", "runtime", "std"] } +wasmtime-wasi = { version = "36.0.2", optional = true, default-features = false, features = ["preview1"] } [target.'cfg(unix)'.dependencies] nix = { version = "0.29", features = ["fs"] } @@ -46,3 +52,5 @@ libc = "0.2" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_System_IO", "Win32_System_Ioctl", "Win32_Storage_FileSystem"] } +[dev-dependencies] +tempfile = "3" diff --git a/host/src/main.rs b/host/src/main.rs index 98f2c4e..593b17c 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -13,6 +13,9 @@ use hyperlight_unikraft::{ }; use std::path::PathBuf; +#[cfg(feature = "wasm-host-fns")] +mod wasm_host_fns; + #[derive(Parser, Debug)] #[command( name = "hyperlight-unikraft", @@ -39,9 +42,63 @@ struct Args { #[arg(long, short = 'q')] quiet: bool, - /// Enable tool dispatch via __dispatch host function - #[arg(long)] - enable_tools: bool, + #[cfg(feature = "wasm-host-fns")] + #[arg( + long = "tool", + value_name = "NAME=WASM", + help = "Register a WASIp1 module as a host tool" + )] + tool: Vec, + + #[cfg(feature = "wasm-host-fns")] + #[arg( + long = "tool-wasi-dir", + value_name = "HOST[:GUEST]", + help = "Preopen a read-write host directory for Wasm tools" + )] + tool_wasi_dir: Vec, + + #[cfg(feature = "wasm-host-fns")] + #[arg( + long = "tool-wasi-dir-ro", + value_name = "HOST[:GUEST]", + help = "Preopen a read-only host directory for Wasm tools" + )] + tool_wasi_dir_ro: Vec, + + #[cfg(feature = "wasm-host-fns")] + #[arg( + long = "tool-wasi-env", + value_name = "KEY=VALUE", + help = "Set an environment variable for Wasm tools" + )] + tool_wasi_env: Vec, + + #[cfg(feature = "wasm-host-fns")] + #[arg( + long = "tool-wasi-env-inherit", + value_name = "KEY", + help = "Inherit one host environment variable into Wasm tools" + )] + tool_wasi_env_inherit: Vec, + + #[cfg(feature = "wasm-host-fns")] + #[arg( + long = "tool-wasi-fuel", + default_value_t = 100_000_000, + value_name = "FUEL", + help = "Fuel units available to each Wasm tool call" + )] + tool_wasi_fuel: u64, + + #[cfg(feature = "wasm-host-fns")] + #[arg( + long = "tool-wasi-output-limit", + default_value = "1Mi", + value_name = "SIZE", + help = "Maximum stdout or stderr captured from one Wasm tool call" + )] + tool_wasi_output_limit: String, /// Preopen a host directory for the guest's sandboxed filesystem. /// @@ -206,6 +263,48 @@ fn main() -> Result<()> { None }; + #[cfg(feature = "wasm-host-fns")] + let wasm_tools = { + if args.tool.is_empty() + && wasm_host_fns::WasmToolOptions::has_capabilities( + &args.tool_wasi_dir, + &args.tool_wasi_dir_ro, + &args.tool_wasi_env, + &args.tool_wasi_env_inherit, + ) + { + return Err(anyhow::anyhow!( + "--tool-wasi-* flags require at least one --tool" + )); + } + if args.tool.is_empty() { + Vec::new() + } else { + let output_limit = parse_memory(&args.tool_wasi_output_limit)?; + let output_limit = usize::try_from(output_limit).map_err(|_| { + anyhow::anyhow!( + "--tool-wasi-output-limit too large: {}", + args.tool_wasi_output_limit + ) + })?; + let options = wasm_host_fns::WasmToolOptions::from_cli( + &args.tool_wasi_dir, + &args.tool_wasi_dir_ro, + &args.tool_wasi_env, + &args.tool_wasi_env_inherit, + args.tool_wasi_fuel, + output_limit, + )?; + let tools = wasm_host_fns::WasmTool::load_all(&args.tool, &options)?; + if !args.quiet { + for tool in &tools { + eprintln!("Tool: {} -> {}", tool.name(), tool.path().display()); + } + } + tools + } + }; + let mut builder = Sandbox::builder(&args.kernel) .args(app_args) .heap_size(heap_size) @@ -222,8 +321,11 @@ fn main() -> Result<()> { if let Some(ports) = listen_ports { builder = builder.listen_ports(ports); } - if args.enable_tools { - builder = builder.tool("echo", Ok); + #[cfg(feature = "wasm-host-fns")] + for tool in wasm_tools { + let name = tool.name().to_string(); + let tool = std::sync::Arc::new(tool); + builder = builder.tool(&name, move |args| tool.invoke(args)); } let mut sandbox = builder.build()?; let evolve_time = t0.elapsed(); diff --git a/host/src/wasm_host_fns.rs b/host/src/wasm_host_fns.rs new file mode 100644 index 0000000..cbf920f --- /dev/null +++ b/host/src/wasm_host_fns.rs @@ -0,0 +1,1124 @@ +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::Value; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use wasmtime::{Config, Engine, InstancePre, Linker, Module, Store}; +use wasmtime_wasi::p2::pipe::{MemoryInputPipe, MemoryOutputPipe}; +use wasmtime_wasi::preview1::{self, WasiP1Ctx}; +use wasmtime_wasi::{DirPerms, FilePerms, I32Exit, WasiCtxBuilder}; + +#[derive(Clone)] +pub struct WasmToolOptions { + dirs: Vec, + env: Vec<(String, String)>, + fuel: u64, + output_limit: usize, +} + +#[derive(Clone)] +struct WasiDir { + host: PathBuf, + guest: String, + read_only: bool, +} + +pub struct WasmTool { + name: String, + path: PathBuf, + engine: Engine, + pre: InstancePre, + options: WasmToolOptions, +} + +impl WasmToolOptions { + pub fn from_cli( + rw_dirs: &[String], + ro_dirs: &[String], + env: &[String], + inherit_env: &[String], + fuel: u64, + output_limit: usize, + ) -> Result { + if fuel == 0 { + bail!("--tool-wasi-fuel must be greater than 0"); + } + if output_limit == 0 { + bail!("--tool-wasi-output-limit must be greater than 0"); + } + + let mut dirs = Vec::with_capacity(rw_dirs.len() + ro_dirs.len()); + for spec in rw_dirs { + dirs.push(parse_wasi_dir(spec, false)?); + } + for spec in ro_dirs { + dirs.push(parse_wasi_dir(spec, true)?); + } + let mut guest_paths = HashSet::new(); + for dir in &dirs { + if !guest_paths.insert(dir.guest.clone()) { + bail!("duplicate Wasm tool WASI guest path: {}", dir.guest); + } + } + + let mut merged_env = Vec::with_capacity(env.len() + inherit_env.len()); + for key in inherit_env { + if key.is_empty() { + bail!("--tool-wasi-env-inherit key must not be empty"); + } + let value = std::env::var(key) + .with_context(|| format!("inherit environment variable {key}"))?; + set_env_pair(&mut merged_env, key.clone(), value); + } + for spec in env { + let (key, value) = parse_env_pair(spec)?; + set_env_pair(&mut merged_env, key, value); + } + + Ok(Self { + dirs, + env: merged_env, + fuel, + output_limit, + }) + } + + pub fn has_capabilities( + rw_dirs: &[String], + ro_dirs: &[String], + env: &[String], + inherit_env: &[String], + ) -> bool { + !rw_dirs.is_empty() || !ro_dirs.is_empty() || !env.is_empty() || !inherit_env.is_empty() + } +} + +impl WasmTool { + pub fn load_all(specs: &[String], options: &WasmToolOptions) -> Result> { + let mut config = Config::new(); + config.consume_fuel(true); + let engine = Engine::new(&config)?; + let mut seen = HashSet::new(); + let mut tools = Vec::with_capacity(specs.len()); + + for spec in specs { + let (name, path) = parse_tool_spec(spec)?; + if !seen.insert(name.clone()) { + bail!("duplicate --tool name: {name}"); + } + let path = std::fs::canonicalize(&path) + .with_context(|| format!("canonicalize Wasm tool {}", path.display()))?; + let module = Module::from_file(&engine, &path) + .with_context(|| format!("compile Wasm tool {}", path.display()))?; + let mut linker: Linker = Linker::new(&engine); + preview1::add_to_linker_sync(&mut linker, |ctx| ctx)?; + let pre = linker + .instantiate_pre(&module) + .with_context(|| format!("link Wasm tool {}", path.display()))?; + tools.push(Self { + name, + path, + engine: engine.clone(), + pre, + options: options.clone(), + }); + } + + Ok(tools) + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn invoke(&self, args: Value) -> Result { + let request = serde_json::json!({ "name": &self.name, "args": args }); + let stdin = serde_json::to_vec(&request)?; + let stdout = MemoryOutputPipe::new(self.options.output_limit); + let stderr = MemoryOutputPipe::new(self.options.output_limit); + + let mut builder = WasiCtxBuilder::new(); + builder + .allow_blocking_current_thread(true) + .arg(self.path.to_string_lossy()) + .arg(&self.name) + .stdin(MemoryInputPipe::new(stdin)) + .stdout(stdout.clone()) + .stderr(stderr.clone()); + + for (key, value) in &self.options.env { + builder.env(key, value); + } + for dir in &self.options.dirs { + let dir_perms = if dir.read_only { + DirPerms::READ + } else { + DirPerms::all() + }; + let file_perms = if dir.read_only { + FilePerms::READ + } else { + FilePerms::all() + }; + builder + .preopened_dir(&dir.host, &dir.guest, dir_perms, file_perms) + .with_context(|| format!("preopen {} as {}", dir.host.display(), dir.guest))?; + } + + let wasi = builder.build_p1(); + let mut store = Store::new(&self.engine, wasi); + store.set_fuel(self.options.fuel)?; + + let instance = self.pre.instantiate(&mut store)?; + let start = instance.get_typed_func::<(), ()>(&mut store, "_start")?; + let status = match start.call(&mut store, ()) { + Ok(()) => 0, + Err(err) => { + if let Some(exit) = err.downcast_ref::() { + exit.0 + } else { + let stderr_text = pipe_text(&stderr); + if stderr_text.trim().is_empty() { + return Err(err) + .with_context(|| format!("Wasm tool {} trapped", self.name)); + } + return Err(err).with_context(|| { + format!( + "Wasm tool {} trapped; stderr: {}", + self.name, + stderr_text.trim() + ) + }); + } + } + }; + + let stdout_bytes = stdout.contents(); + let stdout_len = stdout_bytes.len(); + let stdout_text = String::from_utf8_lossy(&stdout_bytes).into_owned(); + let stderr_text = pipe_text(&stderr); + if status != 0 { + if stderr_text.trim().is_empty() { + bail!("Wasm tool {} exited with status {status}", self.name); + } + bail!( + "Wasm tool {} exited with status {status}; stderr: {}", + self.name, + stderr_text.trim() + ); + } + + match parse_tool_stdout(&self.name, &stdout_text) { + Ok(value) => Ok(value), + Err(err) if stdout_len >= self.options.output_limit => Err(err).with_context(|| { + format!( + "Wasm tool {} stdout may have reached output limit of {} bytes", + self.name, self.options.output_limit + ) + }), + Err(err) => Err(err), + } + } +} + +fn parse_tool_spec(spec: &str) -> Result<(String, PathBuf)> { + let (name, path) = spec + .split_once('=') + .ok_or_else(|| anyhow!("--tool must use NAME=WASM syntax: {spec}"))?; + let name = name.trim(); + let path = path.trim(); + if name.is_empty() { + bail!("--tool name must not be empty: {spec}"); + } + if name.starts_with("__") || name.starts_with("fs_") || name.starts_with("net_") { + bail!("--tool name {name} is reserved"); + } + if path.is_empty() { + bail!("--tool path must not be empty: {spec}"); + } + Ok((name.to_string(), PathBuf::from(path))) +} + +fn parse_wasi_dir(spec: &str, read_only: bool) -> Result { + let (host, guest) = if let Some(idx) = spec.rfind(':') { + let (host, guest) = spec.split_at(idx); + let guest = &guest[1..]; + if is_windows_drive_path(spec, idx) { + (spec, "/host") + } else if guest.starts_with('/') || guest == "." || guest.starts_with("./") { + (host, guest) + } else { + bail!( + "invalid WASI preopen guest path {:?}: expected absolute path, '.', or './path'", + guest + ); + } + } else { + (spec, "/host") + }; + + if host.is_empty() { + bail!("WASI preopen host path must not be empty: {spec}"); + } + if guest.is_empty() { + bail!("WASI preopen guest path must not be empty: {spec}"); + } + + let host = std::fs::canonicalize(host) + .with_context(|| format!("canonicalize WASI preopen host path {host}"))?; + Ok(WasiDir { + host, + guest: guest.to_string(), + read_only, + }) +} + +fn parse_env_pair(spec: &str) -> Result<(String, String)> { + let (key, value) = spec + .split_once('=') + .ok_or_else(|| anyhow!("--tool-wasi-env must use KEY=VALUE syntax: {spec}"))?; + if key.is_empty() { + bail!("--tool-wasi-env key must not be empty: {spec}"); + } + Ok((key.to_string(), value.to_string())) +} + +fn set_env_pair(env: &mut Vec<(String, String)>, key: String, value: String) { + if let Some((_, existing)) = env + .iter_mut() + .find(|(existing_key, _)| existing_key == &key) + { + *existing = value; + } else { + env.push((key, value)); + } +} + +fn is_windows_drive_path(spec: &str, colon_idx: usize) -> bool { + colon_idx == 1 + && spec + .as_bytes() + .first() + .map(|b| b.is_ascii_alphabetic()) + .unwrap_or(false) + && spec + .as_bytes() + .get(2) + .map(|b| *b == b'/' || *b == b'\\') + .unwrap_or(false) +} + +fn pipe_text(pipe: &MemoryOutputPipe) -> String { + String::from_utf8_lossy(&pipe.contents()).into_owned() +} + +fn parse_tool_stdout(name: &str, stdout: &str) -> Result { + let trimmed = stdout.trim(); + if trimmed.is_empty() { + return Ok(Value::Null); + } + + let value: Value = serde_json::from_str(trimmed) + .with_context(|| format!("Wasm tool {name} wrote non-JSON stdout"))?; + if let Some(object) = value.as_object() { + if object.len() == 1 { + if let Some(result) = object.get("result") { + return Ok(result.clone()); + } + if let Some(error) = object.get("error") { + if let Some(message) = error.as_str() { + bail!("Wasm tool {name}: {message}"); + } + bail!("Wasm tool {name}: {error}"); + } + } + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::fs; + use tempfile::TempDir; + + fn tempdir(label: &str) -> TempDir { + tempfile::Builder::new() + .prefix(&format!("hl-wasm-tools-{label}-")) + .tempdir() + .unwrap() + } + + fn default_options() -> WasmToolOptions { + WasmToolOptions::from_cli(&[], &[], &[], &[], 1_000_000, 4096).unwrap() + } + + fn options_with_limits(fuel: u64, output_limit: usize) -> WasmToolOptions { + WasmToolOptions::from_cli(&[], &[], &[], &[], fuel, output_limit).unwrap() + } + + fn load_tool(name: &str, wasm: Vec, options: WasmToolOptions) -> WasmTool { + let dir = tempdir(name); + let path = dir.path().join(format!("{name}.wasm")); + fs::write(&path, wasm).unwrap(); + let specs = vec![format!("{name}={}", path.display())]; + let mut tools = WasmTool::load_all(&specs, &options).unwrap(); + assert_eq!(tools.len(), 1); + tools.remove(0) + } + + fn err_string(err: anyhow::Error) -> String { + format!("{err:#}") + } + + fn encode_u32(mut value: u32, out: &mut Vec) { + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + out.push(byte); + if value == 0 { + break; + } + } + } + + fn encode_i32(mut value: i32, out: &mut Vec) { + loop { + let byte = (value as u8) & 0x7f; + value >>= 7; + let done = (value == 0 && (byte & 0x40) == 0) || (value == -1 && (byte & 0x40) != 0); + if done { + out.push(byte); + break; + } + out.push(byte | 0x80); + } + } + + fn encode_i64(mut value: i64, out: &mut Vec) { + loop { + let byte = (value as u8) & 0x7f; + value >>= 7; + let done = (value == 0 && (byte & 0x40) == 0) || (value == -1 && (byte & 0x40) != 0); + if done { + out.push(byte); + break; + } + out.push(byte | 0x80); + } + } + + fn push_name(out: &mut Vec, name: &str) { + encode_u32(name.len() as u32, out); + out.extend_from_slice(name.as_bytes()); + } + + fn push_section(module: &mut Vec, id: u8, payload: Vec) { + module.push(id); + encode_u32(payload.len() as u32, module); + module.extend(payload); + } + + fn func_type(params: &[u8], results: &[u8]) -> Vec { + let mut out = vec![0x60]; + encode_u32(params.len() as u32, &mut out); + out.extend_from_slice(params); + encode_u32(results.len() as u32, &mut out); + out.extend_from_slice(results); + out + } + + fn i32_const(out: &mut Vec, value: i32) { + out.push(0x41); + encode_i32(value, out); + } + + fn i64_const(out: &mut Vec, value: i64) { + out.push(0x42); + encode_i64(value, out); + } + + fn i32_store(out: &mut Vec) { + out.push(0x36); + encode_u32(2, out); + encode_u32(0, out); + } + + fn i32_load(out: &mut Vec) { + out.push(0x28); + encode_u32(2, out); + encode_u32(0, out); + } + + fn call(out: &mut Vec, index: u32) { + out.push(0x10); + encode_u32(index, out); + } + + fn drop_value(out: &mut Vec) { + out.push(0x1a); + } + + fn end(out: &mut Vec) { + out.push(0x0b); + } + + fn function_body(mut instructions: Vec) -> Vec { + let mut body = vec![0x00]; + if !instructions.ends_with(&[0x0b]) { + instructions.push(0x0b); + } + body.extend(instructions); + body + } + + fn module( + types: Vec>, + imports: Vec<(&str, &str, u32)>, + functions: Vec, + export_start_index: u32, + memory: bool, + bodies: Vec>, + data: Vec<(u32, Vec)>, + ) -> Vec { + let mut module = vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; + + let mut type_payload = Vec::new(); + encode_u32(types.len() as u32, &mut type_payload); + for ty in types { + type_payload.extend(ty); + } + push_section(&mut module, 1, type_payload); + + if !imports.is_empty() { + let mut import_payload = Vec::new(); + encode_u32(imports.len() as u32, &mut import_payload); + for (module_name, field_name, type_index) in imports { + push_name(&mut import_payload, module_name); + push_name(&mut import_payload, field_name); + import_payload.push(0x00); + encode_u32(type_index, &mut import_payload); + } + push_section(&mut module, 2, import_payload); + } + + let mut function_payload = Vec::new(); + encode_u32(functions.len() as u32, &mut function_payload); + for type_index in functions { + encode_u32(type_index, &mut function_payload); + } + push_section(&mut module, 3, function_payload); + + if memory { + let memory_payload = vec![0x01, 0x00, 0x01]; + push_section(&mut module, 5, memory_payload); + } + + let mut export_payload = Vec::new(); + encode_u32(if memory { 2 } else { 1 }, &mut export_payload); + push_name(&mut export_payload, "_start"); + export_payload.push(0x00); + encode_u32(export_start_index, &mut export_payload); + if memory { + push_name(&mut export_payload, "memory"); + export_payload.push(0x02); + encode_u32(0, &mut export_payload); + } + push_section(&mut module, 7, export_payload); + + let mut code_payload = Vec::new(); + encode_u32(bodies.len() as u32, &mut code_payload); + for body in bodies { + encode_u32(body.len() as u32, &mut code_payload); + code_payload.extend(body); + } + push_section(&mut module, 10, code_payload); + + if !data.is_empty() { + let mut data_payload = Vec::new(); + encode_u32(data.len() as u32, &mut data_payload); + for (offset, bytes) in data { + data_payload.push(0x00); + i32_const(&mut data_payload, offset as i32); + end(&mut data_payload); + encode_u32(bytes.len() as u32, &mut data_payload); + data_payload.extend(bytes); + } + push_section(&mut module, 11, data_payload); + } + + module + } + + fn stdout_module(bytes: &[u8]) -> Vec { + let mut instructions = Vec::new(); + i32_const(&mut instructions, 8); + i32_const(&mut instructions, 64); + i32_store(&mut instructions); + i32_const(&mut instructions, 12); + i32_const(&mut instructions, bytes.len() as i32); + i32_store(&mut instructions); + i32_const(&mut instructions, 1); + i32_const(&mut instructions, 8); + i32_const(&mut instructions, 1); + i32_const(&mut instructions, 4); + call(&mut instructions, 0); + drop_value(&mut instructions); + end(&mut instructions); + + module( + vec![ + func_type(&[0x7f, 0x7f, 0x7f, 0x7f], &[0x7f]), + func_type(&[], &[]), + ], + vec![("wasi_snapshot_preview1", "fd_write", 0)], + vec![1], + 1, + true, + vec![function_body(instructions)], + vec![(64, bytes.to_vec())], + ) + } + + fn stdin_echo_module() -> Vec { + let mut instructions = Vec::new(); + i32_const(&mut instructions, 8); + i32_const(&mut instructions, 64); + i32_store(&mut instructions); + i32_const(&mut instructions, 12); + i32_const(&mut instructions, 2048); + i32_store(&mut instructions); + i32_const(&mut instructions, 0); + i32_const(&mut instructions, 8); + i32_const(&mut instructions, 1); + i32_const(&mut instructions, 4); + call(&mut instructions, 0); + drop_value(&mut instructions); + i32_const(&mut instructions, 16); + i32_const(&mut instructions, 64); + i32_store(&mut instructions); + i32_const(&mut instructions, 20); + i32_const(&mut instructions, 4); + i32_load(&mut instructions); + i32_store(&mut instructions); + i32_const(&mut instructions, 1); + i32_const(&mut instructions, 16); + i32_const(&mut instructions, 1); + i32_const(&mut instructions, 24); + call(&mut instructions, 1); + drop_value(&mut instructions); + end(&mut instructions); + + module( + vec![ + func_type(&[0x7f, 0x7f, 0x7f, 0x7f], &[0x7f]), + func_type(&[], &[]), + ], + vec![ + ("wasi_snapshot_preview1", "fd_read", 0), + ("wasi_snapshot_preview1", "fd_write", 0), + ], + vec![1], + 2, + true, + vec![function_body(instructions)], + Vec::new(), + ) + } + + fn env_value_module(key: &str, value_len: usize) -> Vec { + let mut instructions = Vec::new(); + i32_const(&mut instructions, 32); + i32_const(&mut instructions, 128); + call(&mut instructions, 0); + drop_value(&mut instructions); + i32_const(&mut instructions, 8); + i32_const(&mut instructions, 128 + key.len() as i32 + 1); + i32_store(&mut instructions); + i32_const(&mut instructions, 12); + i32_const(&mut instructions, value_len as i32); + i32_store(&mut instructions); + i32_const(&mut instructions, 1); + i32_const(&mut instructions, 8); + i32_const(&mut instructions, 1); + i32_const(&mut instructions, 4); + call(&mut instructions, 1); + drop_value(&mut instructions); + end(&mut instructions); + + module( + vec![ + func_type(&[0x7f, 0x7f], &[0x7f]), + func_type(&[0x7f, 0x7f, 0x7f, 0x7f], &[0x7f]), + func_type(&[], &[]), + ], + vec![ + ("wasi_snapshot_preview1", "environ_get", 0), + ("wasi_snapshot_preview1", "fd_write", 1), + ], + vec![2], + 2, + true, + vec![function_body(instructions)], + Vec::new(), + ) + } + + fn preopen_read_file_module(path: &[u8]) -> Vec { + let mut instructions = Vec::new(); + i32_const(&mut instructions, 3); + i32_const(&mut instructions, 0); + i32_const(&mut instructions, 64); + i32_const(&mut instructions, path.len() as i32); + i32_const(&mut instructions, 0); + i64_const(&mut instructions, 2); + i64_const(&mut instructions, 0); + i32_const(&mut instructions, 0); + i32_const(&mut instructions, 4); + call(&mut instructions, 0); + drop_value(&mut instructions); + i32_const(&mut instructions, 8); + i32_const(&mut instructions, 128); + i32_store(&mut instructions); + i32_const(&mut instructions, 12); + i32_const(&mut instructions, 512); + i32_store(&mut instructions); + i32_const(&mut instructions, 4); + i32_load(&mut instructions); + i32_const(&mut instructions, 8); + i32_const(&mut instructions, 1); + i32_const(&mut instructions, 20); + call(&mut instructions, 1); + drop_value(&mut instructions); + i32_const(&mut instructions, 24); + i32_const(&mut instructions, 128); + i32_store(&mut instructions); + i32_const(&mut instructions, 28); + i32_const(&mut instructions, 20); + i32_load(&mut instructions); + i32_store(&mut instructions); + i32_const(&mut instructions, 1); + i32_const(&mut instructions, 24); + i32_const(&mut instructions, 1); + i32_const(&mut instructions, 32); + call(&mut instructions, 2); + drop_value(&mut instructions); + end(&mut instructions); + + module( + vec![ + func_type( + &[0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x7e, 0x7e, 0x7f, 0x7f], + &[0x7f], + ), + func_type(&[0x7f, 0x7f, 0x7f, 0x7f], &[0x7f]), + func_type(&[], &[]), + ], + vec![ + ("wasi_snapshot_preview1", "path_open", 0), + ("wasi_snapshot_preview1", "fd_read", 1), + ("wasi_snapshot_preview1", "fd_write", 1), + ], + vec![2], + 3, + true, + vec![function_body(instructions)], + vec![(64, path.to_vec())], + ) + } + + fn no_output_module() -> Vec { + module( + vec![func_type(&[], &[])], + Vec::new(), + vec![0], + 0, + false, + vec![function_body(vec![0x0b])], + Vec::new(), + ) + } + + fn infinite_loop_module() -> Vec { + let instructions = vec![0x03, 0x40, 0x0c, 0x00, 0x0b, 0x0b]; + module( + vec![func_type(&[], &[])], + Vec::new(), + vec![0], + 0, + false, + vec![function_body(instructions)], + Vec::new(), + ) + } + + fn stderr_exit_module(stderr: &[u8], code: i32) -> Vec { + let mut instructions = Vec::new(); + i32_const(&mut instructions, 8); + i32_const(&mut instructions, 64); + i32_store(&mut instructions); + i32_const(&mut instructions, 12); + i32_const(&mut instructions, stderr.len() as i32); + i32_store(&mut instructions); + i32_const(&mut instructions, 2); + i32_const(&mut instructions, 8); + i32_const(&mut instructions, 1); + i32_const(&mut instructions, 4); + call(&mut instructions, 0); + drop_value(&mut instructions); + i32_const(&mut instructions, code); + call(&mut instructions, 1); + end(&mut instructions); + + module( + vec![ + func_type(&[0x7f, 0x7f, 0x7f, 0x7f], &[0x7f]), + func_type(&[0x7f], &[]), + func_type(&[], &[]), + ], + vec![ + ("wasi_snapshot_preview1", "fd_write", 0), + ("wasi_snapshot_preview1", "proc_exit", 1), + ], + vec![2], + 2, + true, + vec![function_body(instructions)], + vec![(64, stderr.to_vec())], + ) + } + + fn unknown_import_module() -> Vec { + let mut instructions = Vec::new(); + call(&mut instructions, 0); + end(&mut instructions); + module( + vec![func_type(&[], &[])], + vec![("host", "missing", 0)], + vec![0], + 1, + false, + vec![function_body(instructions)], + Vec::new(), + ) + } + + #[test] + fn parse_tool_spec_accepts_valid_name_and_path() { + let (name, path) = parse_tool_spec(" greet = ./handler.wasm ").unwrap(); + assert_eq!(name, "greet"); + assert_eq!(path, PathBuf::from("./handler.wasm")); + } + + #[test] + fn parse_tool_spec_rejects_invalid_or_reserved_names() { + for spec in [ + "missing_equals", + "=handler.wasm", + "__hl_exit=handler.wasm", + "__dispatch=handler.wasm", + "fs_read=handler.wasm", + "net_socket=handler.wasm", + "greet=", + "greet= ", + ] { + assert!(parse_tool_spec(spec).is_err(), "{spec} should fail"); + } + } + + #[test] + fn cli_options_parse_wasi_dirs_env_and_limits() { + let rw = tempdir("rw"); + let ro = tempdir("ro"); + let opts = WasmToolOptions::from_cli( + &[format!("{}:/rw", rw.path().display())], + &[format!("{}:/ro", ro.path().display())], + &["A=B".to_string(), "EMPTY=".to_string()], + &[], + 123, + 456, + ) + .unwrap(); + + assert_eq!(opts.fuel, 123); + assert_eq!(opts.output_limit, 456); + assert_eq!( + opts.env, + vec![("A".into(), "B".into()), ("EMPTY".into(), "".into())] + ); + assert_eq!(opts.dirs.len(), 2); + assert_eq!(opts.dirs[0].host, fs::canonicalize(rw.path()).unwrap()); + assert_eq!(opts.dirs[0].guest, "/rw"); + assert!(!opts.dirs[0].read_only); + assert_eq!(opts.dirs[1].host, fs::canonicalize(ro.path()).unwrap()); + assert_eq!(opts.dirs[1].guest, "/ro"); + assert!(opts.dirs[1].read_only); + } + + #[test] + fn cli_options_default_wasi_guest_path_to_host() { + let dir = tempdir("default-guest"); + let opts = + WasmToolOptions::from_cli(&[dir.path().display().to_string()], &[], &[], &[], 1, 1) + .unwrap(); + assert_eq!(opts.dirs[0].guest, "/host"); + } + + #[test] + fn cli_options_use_last_explicit_env_value_for_duplicate_keys() { + let opts = WasmToolOptions::from_cli( + &[], + &[], + &[ + "A=first".to_string(), + "B=only".to_string(), + "A=second".to_string(), + ], + &[], + 1, + 1, + ) + .unwrap(); + assert_eq!( + opts.env, + vec![("A".into(), "second".into()), ("B".into(), "only".into())] + ); + } + + #[test] + fn cli_options_reject_invalid_values() { + let dir = tempdir("invalid-options"); + assert!(WasmToolOptions::from_cli(&[], &[], &[], &[], 0, 1).is_err()); + assert!(WasmToolOptions::from_cli(&[], &[], &[], &[], 1, 0).is_err()); + assert!( + WasmToolOptions::from_cli(&[], &[], &["NO_EQUALS".to_string()], &[], 1, 1).is_err() + ); + assert!(WasmToolOptions::from_cli(&[], &[], &["=value".to_string()], &[], 1, 1).is_err()); + assert!(WasmToolOptions::from_cli(&[], &[], &[], &["".to_string()], 1, 1).is_err()); + assert!(WasmToolOptions::from_cli( + &[format!("{}:relative", dir.path().display())], + &[], + &[], + &[], + 1, + 1, + ) + .is_err()); + assert!(WasmToolOptions::from_cli( + &[ + format!("{}:/dup", dir.path().display()), + format!("{}:/dup", dir.path().display()) + ], + &[], + &[], + &[], + 1, + 1, + ) + .is_err()); + } + + #[test] + fn has_capabilities_detects_any_wasi_capability_flag() { + assert!(!WasmToolOptions::has_capabilities(&[], &[], &[], &[])); + assert!(WasmToolOptions::has_capabilities( + &[".".into()], + &[], + &[], + &[] + )); + assert!(WasmToolOptions::has_capabilities( + &[], + &[".".into()], + &[], + &[] + )); + assert!(WasmToolOptions::has_capabilities( + &[], + &[], + &["A=B".into()], + &[] + )); + assert!(WasmToolOptions::has_capabilities( + &[], + &[], + &[], + &["PATH".into()] + )); + } + + #[test] + fn parse_tool_stdout_handles_raw_values_and_envelopes() { + assert_eq!(parse_tool_stdout("t", "").unwrap(), Value::Null); + assert_eq!(parse_tool_stdout("t", " \n ").unwrap(), Value::Null); + assert_eq!(parse_tool_stdout("t", "42").unwrap(), json!(42)); + assert_eq!( + parse_tool_stdout("t", "{\"result\":{\"ok\":true}}").unwrap(), + json!({"ok": true}) + ); + assert_eq!( + parse_tool_stdout("t", "{\"result\":1,\"extra\":2}").unwrap(), + json!({"result": 1, "extra": 2}) + ); + assert!( + err_string(parse_tool_stdout("t", "{\"error\":\"boom\"}").unwrap_err()) + .contains("Wasm tool t: boom") + ); + assert!(err_string(parse_tool_stdout("t", "not json").unwrap_err()) + .contains("wrote non-JSON stdout")); + } + + #[test] + fn load_all_rejects_duplicate_names_invalid_wasm_and_unknown_imports() { + let dir = tempdir("load-errors"); + let ok = dir.path().join("ok.wasm"); + let bad = dir.path().join("bad.wasm"); + let unknown = dir.path().join("unknown.wasm"); + fs::write(&ok, no_output_module()).unwrap(); + fs::write(&bad, b"not wasm").unwrap(); + fs::write(&unknown, unknown_import_module()).unwrap(); + let options = default_options(); + + let duplicate_err = WasmTool::load_all( + &[format!("a={}", ok.display()), format!("a={}", ok.display())], + &options, + ) + .err() + .expect("duplicate tool name should fail"); + assert!(err_string(duplicate_err).contains("duplicate --tool name")); + + let invalid_err = WasmTool::load_all(&[format!("bad={}", bad.display())], &options) + .err() + .expect("invalid wasm should fail"); + assert!(err_string(invalid_err).contains("compile Wasm tool")); + + let link_err = WasmTool::load_all(&[format!("unknown={}", unknown.display())], &options) + .err() + .expect("unknown import should fail"); + assert!(err_string(link_err).contains("link Wasm tool")); + } + + #[test] + fn invoke_passes_dispatch_request_on_stdin() { + let tool = load_tool("echo_req", stdin_echo_module(), default_options()); + let result = tool.invoke(json!({"n": 7, "s": "hello"})).unwrap(); + assert_eq!(result["name"], "echo_req"); + assert_eq!(result["args"], json!({"n": 7, "s": "hello"})); + } + + #[test] + fn invoke_passes_configured_environment() { + let key = "HL_WASM_JSON"; + let value = r#"{"result":"env-ok"}"#; + let options = + WasmToolOptions::from_cli(&[], &[], &[format!("{key}={value}")], &[], 1_000_000, 4096) + .unwrap(); + let tool = load_tool("env", env_value_module(key, value.len()), options); + let result = tool.invoke(json!({})).unwrap(); + assert_eq!(result, json!("env-ok")); + } + + #[test] + fn invoke_can_read_explicit_read_only_preopen() { + let root = tempdir("preopen-read"); + fs::write(root.path().join("answer.json"), br#"{"result":"file-ok"}"#).unwrap(); + let options = WasmToolOptions::from_cli( + &[], + &[format!("{}:.", root.path().display())], + &[], + &[], + 1_000_000, + 4096, + ) + .unwrap(); + let tool = load_tool( + "read_preopen", + preopen_read_file_module(b"answer.json"), + options, + ); + let result = tool.invoke(json!({})).unwrap(); + assert_eq!(result, json!("file-ok")); + } + + #[test] + fn invoke_unwraps_result_envelope() { + let tool = load_tool( + "answer", + stdout_module(br#"{"result":{"ok":true,"answer":42}}"#), + default_options(), + ); + let result = tool.invoke(json!({"ignored": true})).unwrap(); + assert_eq!(result, json!({"ok": true, "answer": 42})); + } + + #[test] + fn invoke_returns_null_for_empty_stdout() { + let tool = load_tool("empty", no_output_module(), default_options()); + let result = tool.invoke(json!({})).unwrap(); + assert_eq!(result, Value::Null); + } + + #[test] + fn invoke_converts_error_envelope_to_handler_error() { + let tool = load_tool( + "fail", + stdout_module(br#"{"error":"boom"}"#), + default_options(), + ); + let err = tool.invoke(json!({})).unwrap_err(); + assert!(err_string(err).contains("Wasm tool fail: boom")); + } + + #[test] + fn invoke_reports_nonzero_exit_status() { + let tool = load_tool("exit_only", stderr_exit_module(b"", 9), default_options()); + let err = tool.invoke(json!({})).unwrap_err(); + assert!(err_string(err).contains("exited with status 9")); + } + + #[test] + fn invoke_includes_stderr_for_nonzero_exit() { + let tool = load_tool( + "stderr_exit", + stderr_exit_module(b"details from stderr", 7), + default_options(), + ); + let err = tool.invoke(json!({})).unwrap_err(); + let msg = err_string(err); + assert!(msg.contains("exited with status 7")); + assert!(msg.contains("details from stderr")); + } + + #[test] + fn invoke_traps_when_stdout_exceeds_limit() { + let tool = load_tool( + "too_much", + stdout_module(br#"{"result":"this is too long"}"#), + options_with_limits(1_000_000, 8), + ); + let err = tool.invoke(json!({})).unwrap_err(); + let msg = err_string(err); + assert!(msg.contains("stdout may have reached output limit of 8 bytes")); + assert!(msg.contains("wrote non-JSON stdout")); + } + + #[test] + fn invoke_traps_when_fuel_is_exhausted() { + let tool = load_tool( + "spin", + infinite_loop_module(), + options_with_limits(10, 1024), + ); + let err = tool.invoke(json!({})).unwrap_err(); + let msg = err_string(err); + assert!(msg.contains("Wasm tool spin trapped")); + assert!(msg.contains("fuel")); + } +}