diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7a5c3e1c..e1a5213b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -36,3 +36,11 @@ updates: target-branch: "main" labels: - "kind/dependencies" + - package-ecosystem: "nuget" + directory: "/dotnet" + schedule: + interval: "daily" + time: "03:00" + target-branch: "main" + labels: + - "kind/dependencies" diff --git a/.gitignore b/.gitignore index d81ee86a..33a8408d 100644 --- a/.gitignore +++ b/.gitignore @@ -457,6 +457,7 @@ $RECYCLE.BIN/ ## Hyperlight **/include/hyperlight*.h +!src/hyperlight_wasm_capi/include/hyperlight_wasm.h **/include/printf.h **/native/HyperlightGuest.lib **/native/HyperlightGuest.pdb diff --git a/CHANGELOG.md b/CHANGELOG.md index 15d3fe67..92fb8d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Prerelease] - Unreleased +### Added + +- Added the `hyperlight-wasm-capi` crate, a C API that calls component functions with arguments and results encoded in hyperlight's component wire format. +- Added the `Hyperlight.Wasm` and `Hyperlight.Wasm.Build` .NET packages. They run components from .NET with typed C# bindings generated from WIT at build time. See `dotnet/README.md`. +- Added `SandboxBuilder::with_runtime_file` to load the guest runtime from a file, and `SandboxBuilder::with_interrupt_vcpu_sigrtmin_offset` to change the interrupt signal on Linux. + +### Changed + +- **BREAKING CHANGE:** Component functions use hyperlight's wire names and little-endian encoding, so hosts and guests built with earlier versions cannot call each other. + +### Fixed + +- Wasm traps report an error instead of aborting the guest with a general protection fault. + ## [v0.15.0] - 2026-08 ### Changed diff --git a/Cargo.lock b/Cargo.lock index b19931cc..0fc0f392 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "allocator-api2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c880a97d28a3681c0267bd29cff89621202715b065127cd445fa0f0fe0aa2880" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -187,21 +193,20 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bindgen" -version = "0.72.1" +version = "0.73.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +checksum = "787ef8ef523575546b106a58213d6e6b06198a05c2f757258c68a74273670cfa" dependencies = [ "bitflags 2.13.2", "cexpr", "clang-sys", - "itertools 0.13.0", "log", "prettyplease 0.2.37", "proc-macro2", "quote", "regex", "rustc-hash", - "shlex 1.3.0", + "shlex", "syn 2.0.119", ] @@ -258,15 +263,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "buddy_system_allocator" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1af5a01adeeade54c9f5060300227a8ee64c05b6376f28e9b8ee3b72dd2056f" -dependencies = [ - "spin 0.10.1", -] - [[package]] name = "built" version = "0.8.1" @@ -283,7 +279,7 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" dependencies = [ - "allocator-api2", + "allocator-api2 0.2.21", ] [[package]] @@ -402,7 +398,7 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex 2.0.1", + "shlex", ] [[package]] @@ -1858,8 +1854,7 @@ dependencies = [ [[package]] name = "hyperlight-common" version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "360d5876788b26af66bf055bca6b4f2c9c930a0fdcaebda4bd7f808c6bd93068" +source = "git+https://github.com/hyperlight-dev/hyperlight?branch=component-wire-format#ca2b8f78bd53a5fe168b208c92c3888bcc2e8535" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -1869,17 +1864,24 @@ dependencies = [ "flatbuffers", "log", "smallvec", - "spin 0.12.3", + "spin", "thiserror", "tracing", "tracing-core", ] +[[package]] +name = "hyperlight-component-csharp" +version = "0.18.0" +source = "git+https://github.com/hyperlight-dev/hyperlight?branch=component-wire-format#ca2b8f78bd53a5fe168b208c92c3888bcc2e8535" +dependencies = [ + "hyperlight-component-util", +] + [[package]] name = "hyperlight-component-macro" version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec066e71934ebaddf70345ead15c4d01df880a9cd52fafb2934a559c4fa8a991" +source = "git+https://github.com/hyperlight-dev/hyperlight?branch=component-wire-format#ca2b8f78bd53a5fe168b208c92c3888bcc2e8535" dependencies = [ "env_logger", "hyperlight-component-util", @@ -1893,8 +1895,7 @@ dependencies = [ [[package]] name = "hyperlight-component-util" version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271a2a808bc2e6b0058ca8dd6aea81c0dca91ec7919d167315289190bb64e767" +source = "git+https://github.com/hyperlight-dev/hyperlight?branch=component-wire-format#ca2b8f78bd53a5fe168b208c92c3888bcc2e8535" dependencies = [ "itertools 0.15.0", "prettyplease 0.3.0", @@ -1911,8 +1912,7 @@ dependencies = [ [[package]] name = "hyperlight-guest" version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faceeab52cbc0f128e75fa4b0ee9fce17c3c462d7776feca66f41e98bf2a4bb" +source = "git+https://github.com/hyperlight-dev/hyperlight?branch=component-wire-format#ca2b8f78bd53a5fe168b208c92c3888bcc2e8535" dependencies = [ "anyhow", "flatbuffers", @@ -1926,10 +1926,8 @@ dependencies = [ [[package]] name = "hyperlight-guest-bin" version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7eff3c3b94ae4da51f03d535e9a13a4132f5f87694a19514c038a9517cfd750" +source = "git+https://github.com/hyperlight-dev/hyperlight?branch=component-wire-format#ca2b8f78bd53a5fe168b208c92c3888bcc2e8535" dependencies = [ - "buddy_system_allocator", "flatbuffers", "hyperlight-common", "hyperlight-guest", @@ -1938,15 +1936,15 @@ dependencies = [ "hyperlight-libc", "linkme", "log", - "spin 0.12.3", + "spin", + "talc", "tracing", ] [[package]] name = "hyperlight-guest-macro" version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f957baa8d97e2787589bf084a18096c08ab4dd22d4f5a040f153f9f719330ec3" +source = "git+https://github.com/hyperlight-dev/hyperlight?branch=component-wire-format#ca2b8f78bd53a5fe168b208c92c3888bcc2e8535" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -1957,11 +1955,10 @@ dependencies = [ [[package]] name = "hyperlight-guest-tracing" version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a12c240f87acd92e80ea08e9a405e781e4a15b9f38fee5fe261e9ce69b816d3" +source = "git+https://github.com/hyperlight-dev/hyperlight?branch=component-wire-format#ca2b8f78bd53a5fe168b208c92c3888bcc2e8535" dependencies = [ "hyperlight-common", - "spin 0.12.3", + "spin", "tracing", "tracing-core", ] @@ -1969,8 +1966,7 @@ dependencies = [ [[package]] name = "hyperlight-host" version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40f8b85ddd688890477158b9921b228973d1b70c84b866698838df0f32d2aa44" +source = "git+https://github.com/hyperlight-dev/hyperlight?branch=component-wire-format#ca2b8f78bd53a5fe168b208c92c3888bcc2e8535" dependencies = [ "anyhow", "bindgen", @@ -1999,7 +1995,7 @@ dependencies = [ "mshv-bindings", "mshv-ioctls", "oci-spec", - "opentelemetry", + "opentelemetry 0.33.1", "page_size", "parking_lot", "rand 0.10.3", @@ -2012,7 +2008,7 @@ dependencies = [ "thiserror", "tracing", "tracing-core", - "tracing-opentelemetry", + "tracing-opentelemetry 0.34.0", "uuid", "vmm-sys-util", "windows", @@ -2024,8 +2020,7 @@ dependencies = [ [[package]] name = "hyperlight-libc" version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a74af1c1cc60140c000a6ebd4eba3121bc2b8f56f134163c25eadca1146b73e0" +source = "git+https://github.com/hyperlight-dev/hyperlight?branch=component-wire-format#ca2b8f78bd53a5fe168b208c92c3888bcc2e8535" dependencies = [ "anyhow", "bindgen", @@ -2061,7 +2056,7 @@ dependencies = [ "metrics-exporter-prometheus", "metrics-util", "once_cell", - "opentelemetry", + "opentelemetry 0.32.0", "opentelemetry-otlp", "opentelemetry-semantic-conventions", "opentelemetry_sdk", @@ -2074,7 +2069,7 @@ dependencies = [ "toml", "tracing", "tracing-forest", - "tracing-opentelemetry", + "tracing-opentelemetry 0.33.0", "tracing-subscriber", "uuid", "windows", @@ -2092,6 +2087,21 @@ dependencies = [ "wasmtime 47.0.4", ] +[[package]] +name = "hyperlight-wasm-bindgen" +version = "0.15.0" +dependencies = [ + "hyperlight-component-csharp", +] + +[[package]] +name = "hyperlight-wasm-capi" +version = "0.15.0" +dependencies = [ + "examples_common", + "hyperlight-wasm", +] + [[package]] name = "hyperlight-wasm-macro" version = "0.15.0" @@ -2116,7 +2126,7 @@ dependencies = [ "hyperlight-guest", "hyperlight-guest-bin", "hyperlight-wasm-macro", - "spin 0.12.3", + "spin", "tracing", "wasmtime 36.0.18", "wasmtime 47.0.4", @@ -2872,6 +2882,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "opentelemetry" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c1f1a6ac9c875a4941e4c7c085b61e8f4daa326de03b4701768c3491a6ad9ee" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + [[package]] name = "opentelemetry-http" version = "0.32.0" @@ -2881,7 +2905,7 @@ dependencies = [ "async-trait", "bytes", "http", - "opentelemetry", + "opentelemetry 0.32.0", "reqwest", ] @@ -2892,7 +2916,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" dependencies = [ "http", - "opentelemetry", + "opentelemetry 0.32.0", "opentelemetry-http", "opentelemetry-proto", "opentelemetry_sdk", @@ -2910,7 +2934,7 @@ version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" dependencies = [ - "opentelemetry", + "opentelemetry 0.32.0", "opentelemetry_sdk", "prost", "tonic", @@ -2932,7 +2956,7 @@ dependencies = [ "futures-channel", "futures-executor", "futures-util", - "opentelemetry", + "opentelemetry 0.32.0", "percent-encoding", "portable-atomic", "rand 0.9.5", @@ -3410,7 +3434,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5216b1837de2149f8bc8e6d5f88a9326b63b8c836ed58ce4a0a29ec736a59734" dependencies = [ - "allocator-api2", + "allocator-api2 0.2.21", "bumpalo", "hashbrown 0.15.5", "log", @@ -3424,7 +3448,7 @@ version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85" dependencies = [ - "allocator-api2", + "allocator-api2 0.2.21", "bumpalo", "hashbrown 0.17.1", "log", @@ -3828,12 +3852,6 @@ dependencies = [ "dirs", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "shlex" version = "2.0.1" @@ -3887,12 +3905,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spin" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" - [[package]] name = "spin" version = "0.12.3" @@ -3980,6 +3992,16 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "talc" +version = "5.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53f8ad14fa1c1b8635114abf9721076fe1e869572a5f26a52690f407292c80dc" +dependencies = [ + "allocator-api2 0.4.0", + "lock_api", +] + [[package]] name = "tar" version = "0.4.46" @@ -4345,7 +4367,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26" dependencies = [ "js-sys", - "opentelemetry", + "opentelemetry 0.32.0", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + +[[package]] +name = "tracing-opentelemetry" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a904802a1b902f43638b677ff2a650847e3b4404101b6c586d648e8c1e3e8fe" +dependencies = [ + "js-sys", + "opentelemetry 0.33.1", "smallvec", "tracing", "tracing-core", diff --git a/Cargo.toml b/Cargo.toml index 72a6df1f..2c987333 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [ "src/hyperlight_wasm", "src/examples_common", "src/hyperlight_wasm_aot", "src/hyperlight_wasm_runtime", "src/hyperlight_wasm_macro" ] +members = [ "src/hyperlight_wasm", "src/hyperlight_wasm_capi", "src/hyperlight_wasm_bindgen", "src/examples_common", "src/hyperlight_wasm_aot", "src/hyperlight_wasm_runtime", "src/hyperlight_wasm_macro" ] exclude = [ "src/tests/rust_guests/rust_wasm_samples", "src/tests/rust_guests/component_sample", "src/tests/rust_guests/greeter_sample" ] resolver = "2" @@ -15,9 +15,24 @@ readme = "README.md" [workspace.dependencies] hyperlight-common = { version = "0.18.0", default-features = false } hyperlight-component-macro = "0.18.0" +hyperlight-component-csharp = "0.18.0" hyperlight-component-util = "0.18.0" hyperlight-guest = "0.18.0" hyperlight-guest-bin = "0.18.0" hyperlight-host = { version = "0.18.0", default-features = false } hyperlight-wasm-macro = { version = "0.15.0", path = "src/hyperlight_wasm_macro" } hyperlight-wasm-runtime = { version = "0.15.0", path = "src/hyperlight_wasm_runtime" } + +# Unreleased hyperlight with the component wire format and C# bindings. +# Remove once hyperlight releases these changes. +[patch.crates-io] +hyperlight-host = { git = "https://github.com/hyperlight-dev/hyperlight", branch = "component-wire-format" } +hyperlight-common = { git = "https://github.com/hyperlight-dev/hyperlight", branch = "component-wire-format" } +hyperlight-guest = { git = "https://github.com/hyperlight-dev/hyperlight", branch = "component-wire-format" } +hyperlight-guest-bin = { git = "https://github.com/hyperlight-dev/hyperlight", branch = "component-wire-format" } +hyperlight-guest-macro = { git = "https://github.com/hyperlight-dev/hyperlight", branch = "component-wire-format" } +hyperlight-guest-tracing = { git = "https://github.com/hyperlight-dev/hyperlight", branch = "component-wire-format" } +hyperlight-libc = { git = "https://github.com/hyperlight-dev/hyperlight", branch = "component-wire-format" } +hyperlight-component-macro = { git = "https://github.com/hyperlight-dev/hyperlight", branch = "component-wire-format" } +hyperlight-component-util = { git = "https://github.com/hyperlight-dev/hyperlight", branch = "component-wire-format" } +hyperlight-component-csharp = { git = "https://github.com/hyperlight-dev/hyperlight", branch = "component-wire-format" } diff --git a/Justfile b/Justfile index 360bf132..05212929 100644 --- a/Justfile +++ b/Justfile @@ -124,6 +124,22 @@ examples-components target=default-target features="": (build-rust-component-exa {{ wit-world }} cargo run {{ if features =="" {''} else {"--no-default-features -F kvm -F " + features } }} --profile={{ if target == "debug" {"dev"} else { target } }} --example component_example {{ wit-world-c }} cargo run {{ if features =="" {''} else {"--no-default-features -F kvm -F " + features } }} --profile={{ if target == "debug" {"dev"} else { target } }} --example c-component {{ wit-world }} {{ wit-world-name-greeter }} cargo run {{ if features =="" {''} else {"--no-default-features -F kvm -F " + features } }} --profile={{ if target == "debug" {"dev"} else { target } }} --example component_greeter_example + {{ wit-world }} cargo test -p hyperlight-wasm-capi --profile={{ if target == "debug" {"dev"} else { target } }} -- --include-ignored + +# Builds the native libraries, and a guest runtime and component for the component_sample example world, for the .NET tests +dotnet-test-assets target=default-target: (build-rust-component-examples target) + cargo build -p hyperlight-wasm-capi -p hyperlight-wasm-bindgen --profile={{ if target == "debug" {"dev"} else { target } }} + {{ wit-world }} cargo hyperlight build --profile={{ if target == "debug" {"dev"} else { target } }} --manifest-path src/hyperlight_wasm_runtime/Cargo.toml --target-dir target/component-sample-runtime --locked + +# Runs the .NET tests. Needs the .NET 8 SDK or later. +dotnet-test target=default-target: (dotnet-test-assets target) + dotnet test dotnet/tests/Hyperlight.Wasm.Tests -c {{ if target == "debug" {"Debug"} else {"Release"} }} + +# Packs the .NET packages for this machine's runtime identifier into dotnet/artifacts +dotnet-pack target="release": + cargo build -p hyperlight-wasm-capi -p hyperlight-wasm-bindgen --profile={{ if target == "debug" {"dev"} else { target } }} + dotnet pack dotnet/src/Hyperlight.Wasm -c {{ if target == "debug" {"Debug"} else {"Release"} }} -o dotnet/artifacts + dotnet pack dotnet/src/Hyperlight.Wasm.Build -c {{ if target == "debug" {"Debug"} else {"Release"} }} -o dotnet/artifacts # Test a component and a module compiled with pulley examples-pulley target=default-target features="": (build-pulley-rust-component-examples target features) (build-pulley-rust-wasm-examples target features) diff --git a/README.md b/README.md index 878764a3..1a9a002f 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,13 @@ WIT_WORLD= HYPERLIGHT_COMPONENT_MACRO_DEBUG=/tmp/guest.rs HYPERLIGHT_COMPONENT_MACRO_DEBUG=/tmp/host.rs cargo build ``` +### Other languages + +* [`src/hyperlight_wasm_capi`](src/hyperlight_wasm_capi) is a C API. Its header is `include/hyperlight_wasm.h`. +* [`dotnet`](dotnet) has .NET packages with C# bindings generated from WIT. + +Both load a guest runtime built for the component's world from a file. + ## Code of Conduct diff --git a/dotnet/Directory.Build.props b/dotnet/Directory.Build.props new file mode 100644 index 00000000..a4b8951f --- /dev/null +++ b/dotnet/Directory.Build.props @@ -0,0 +1,23 @@ + + + + enable + enable + latest + true + 0.15.0 + The Hyperlight Authors + Apache-2.0 + https://github.com/hyperlight-dev/hyperlight-wasm + https://github.com/hyperlight-dev/hyperlight-wasm + + release + debug + $(MSBuildThisFileDirectory)../target/$(HyperlightCargoProfile)/ + lib + .dll + .so + .dylib + + + diff --git a/dotnet/Directory.Build.targets b/dotnet/Directory.Build.targets new file mode 100644 index 00000000..49cbec8a --- /dev/null +++ b/dotnet/Directory.Build.targets @@ -0,0 +1,8 @@ + + + + + $(NETCoreSdkRuntimeIdentifier) + + + diff --git a/dotnet/README.md b/dotnet/README.md new file mode 100644 index 00000000..90013620 --- /dev/null +++ b/dotnet/README.md @@ -0,0 +1,72 @@ +# Hyperlight.Wasm for .NET + +Run WebAssembly components in Hyperlight micro VMs from .NET, with typed bindings generated from WIT. + +* `Hyperlight.Wasm` is the runtime library. It wraps the native `hyperlight-wasm-capi` library. +* `Hyperlight.Wasm.Build` generates C# from WIT at build time. It needs no Rust toolchain. + +Both target .NET 8 or later. The packages hold native libraries for the runtime identifier they were packed on. + +## Usage + +```xml + + + + + +``` + +Each `HyperlightWit` item is a `.wit` file or directory. `World` selects a world when the package has several. Without it, the last world is used. `Namespace` sets the namespace of the generated types and defaults to the project's root namespace. + +For a world `example` that imports `host` and exports `adder`, the build generates: + +* `ExampleComponent`, with an `Adder` property for the export. +* `ExampleComponent.RegisterImports`, which registers an `IHost` implementation as host functions. +* C# types for the world's records, variants, enums and flags. + +```csharp +using var sandbox = new WasmSandbox(new SandboxOptions +{ + RuntimePath = "example-runtime", +}); +ExampleComponent.RegisterImports(sandbox, new Host()); +sandbox.Load("example.aot"); + +var component = new ExampleComponent(sandbox); +uint sum = component.Adder.Add(1, 2); +``` + +`docs/component-wire-format.md` in hyperlight lists how WIT types map to C#. + +## Guest runtime and components + +A component needs a guest runtime built for its WIT world: + +```sh +WIT_WORLD=example-world.wasm cargo hyperlight build --release \ + --manifest-path src/hyperlight_wasm_runtime/Cargo.toml --target-dir target/example-runtime +``` + +`example-world.wasm` comes from `wasm-tools component wit example.wit -w -o example-world.wasm`. Precompile the component with `hyperlight-wasm-aot compile --component`. The runtime, the AOT tool and the packages must come from the same hyperlight-wasm version. + +## Sandboxes + +* Register host functions, then load a component, then call it. +* Calls are serialized. Host functions run on the calling thread and cannot call back into their own sandbox. +* An exception thrown by a host function fails the guest call. The `HyperlightException` holds it as `InnerException`. +* A trap or interrupted call poisons the sandbox. Restore a snapshot taken with `CreateSnapshot` to use it again. +* `Call` takes a `CancellationToken`. Cancelling interrupts the guest. `CreateInterruptHandle` returns a handle that does the same from any thread. + +## Interrupt signal on Linux + +Hyperlight interrupts a running guest with the signal `SIGRTMIN + InterruptSignalOffset`. The .NET runtime suspends threads for garbage collection with `SIGRTMIN`, so the offset defaults to 1. With offset 0 a garbage collection can hang. + +## Development + +```sh +just dotnet-test # builds the native libraries, a test runtime and component, then runs the tests +just dotnet-pack # packs both packages into dotnet/artifacts +``` + +`HyperlightNativeDir` overrides where the projects find the native libraries. diff --git a/dotnet/src/Hyperlight.Wasm.Build/GenerateHyperlightBindings.cs b/dotnet/src/Hyperlight.Wasm.Build/GenerateHyperlightBindings.cs new file mode 100644 index 00000000..24eb4fd2 --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm.Build/GenerateHyperlightBindings.cs @@ -0,0 +1,128 @@ +// Copyright 2026 The Hyperlight Authors. +// SPDX-License-Identifier: Apache-2.0 + +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Hyperlight.Wasm.Build; + +/// Generates C# bindings for each WIT item. +public sealed unsafe class GenerateHyperlightBindings : Microsoft.Build.Utilities.Task +{ + /// + /// WIT files or directories. Optional metadata: World selects a + /// world and Namespace overrides . + /// + [Required] + public ITaskItem[] Wit { get; set; } = []; + + /// The native hyperlight_wasm_bindgen library. + [Required] + public string BindgenLibrary { get; set; } = ""; + + /// The directory for generated files. + [Required] + public string OutputDirectory { get; set; } = ""; + + /// The namespace of items without Namespace metadata. + public string DefaultNamespace { get; set; } = ""; + + /// The generated C# files. + [Output] + public ITaskItem[] Generated { get; set; } = []; + + /// + public override bool Execute() + { + nint library; + try + { + library = NativeLibrary.Load(BindgenLibrary); + } + catch (Exception e) + { + Log.LogError("Cannot load the Hyperlight bindings generator '{0}': {1}", BindgenLibrary, e.Message); + return false; + } + try + { + var generate = (delegate* unmanaged) + NativeLibrary.GetExport(library, "hlw_bindgen_csharp"); + var free = (delegate* unmanaged)NativeLibrary.GetExport(library, "hlw_bindgen_free"); + Directory.CreateDirectory(OutputDirectory); + var generated = new List(); + var outputs = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in Wit) + { + var wit = Path.GetFullPath(item.ItemSpec); + var world = item.GetMetadata("World"); + var ns = item.GetMetadata("Namespace"); + if (ns.Length == 0) + { + ns = DefaultNamespace; + } + if (!TryGenerate(generate, free, wit, world, ns, out var text)) + { + Log.LogError(null, "HLWIT001", null, item.ItemSpec, 0, 0, 0, 0, "Cannot generate Hyperlight bindings: {0}", text); + continue; + } + var name = Path.GetFileNameWithoutExtension(wit.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + if (world.Length > 0) + { + name += "." + world; + } + var output = Path.Combine(OutputDirectory, name + ".g.cs"); + if (!outputs.Add(output)) + { + Log.LogError(null, "HLWIT002", null, item.ItemSpec, 0, 0, 0, 0, "Two HyperlightWit items generate '{0}'. Give them different World metadata or file names.", output); + continue; + } + // Rewriting unchanged output would force a recompile. + if (!File.Exists(output) || File.ReadAllText(output) != text) + { + File.WriteAllText(output, text); + } + generated.Add(new TaskItem(output)); + } + Generated = [.. generated]; + return !Log.HasLoggedErrors; + } + finally + { + NativeLibrary.Free(library); + } + } + + private static bool TryGenerate( + delegate* unmanaged generate, + delegate* unmanaged free, + string wit, + string world, + string ns, + out string text) + { + var witBytes = Encoding.UTF8.GetBytes(wit); + var worldBytes = Encoding.UTF8.GetBytes(world); + var nsBytes = Encoding.UTF8.GetBytes(ns); + byte* output; + nuint length; + int status; + fixed (byte* w = witBytes) + fixed (byte* wo = worldBytes) + fixed (byte* n = nsBytes) + { + status = generate(w, (nuint)witBytes.Length, wo, (nuint)worldBytes.Length, n, (nuint)nsBytes.Length, &output, &length); + } + try + { + text = Encoding.UTF8.GetString(output, checked((int)length)); + } + finally + { + free(output, length); + } + return status == 0; + } +} diff --git a/dotnet/src/Hyperlight.Wasm.Build/Hyperlight.Wasm.Build.csproj b/dotnet/src/Hyperlight.Wasm.Build/Hyperlight.Wasm.Build.csproj new file mode 100644 index 00000000..2e56304f --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm.Build/Hyperlight.Wasm.Build.csproj @@ -0,0 +1,36 @@ + + + + net8.0 + true + Generates C# bindings for Hyperlight.Wasm from WIT files listed as HyperlightWit items. + hyperlight;wasm;webassembly;component;wit;bindgen + true + + false + $(NoWarn);NU5100;NU5128 + true + + + + + + + + $(HyperlightNativeDir)$(HyperlightNativePrefix)hyperlight_wasm_bindgen$(HyperlightNativeSuffix) + + + + + + + + + + + + diff --git a/dotnet/src/Hyperlight.Wasm.Build/build/Hyperlight.Wasm.Build.props b/dotnet/src/Hyperlight.Wasm.Build/build/Hyperlight.Wasm.Build.props new file mode 100644 index 00000000..8072c94d --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm.Build/build/Hyperlight.Wasm.Build.props @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/dotnet/src/Hyperlight.Wasm.Build/build/Hyperlight.Wasm.Build.targets b/dotnet/src/Hyperlight.Wasm.Build/build/Hyperlight.Wasm.Build.targets new file mode 100644 index 00000000..613877cd --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm.Build/build/Hyperlight.Wasm.Build.targets @@ -0,0 +1,35 @@ + + + + $(MSBuildThisFileDirectory)../tasks/net8.0/Hyperlight.Wasm.Build.dll + <_HyperlightBindgenFile Condition="$([MSBuild]::IsOSPlatform('Windows'))">hyperlight_wasm_bindgen.dll + <_HyperlightBindgenFile Condition="$([MSBuild]::IsOSPlatform('Linux'))">libhyperlight_wasm_bindgen.so + <_HyperlightBindgenFile Condition="$([MSBuild]::IsOSPlatform('OSX'))">libhyperlight_wasm_bindgen.dylib + $(MSBuildThisFileDirectory)../tasks/net8.0/runtimes/$(NETCoreSdkRuntimeIdentifier)/native/$(_HyperlightBindgenFile) + + + + + + + + + $(IntermediateOutputPath)HyperlightBindings/ + + + + + + + + + + + diff --git a/dotnet/src/Hyperlight.Wasm/Component/Runtime.cs b/dotnet/src/Hyperlight.Wasm/Component/Runtime.cs new file mode 100644 index 00000000..7ab7c96c --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm/Component/Runtime.cs @@ -0,0 +1,475 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +// Support types for C# bindings generated by hyperlight-component-csharp. +// The encoding is specified in docs/component-wire-format.md. + +#nullable enable +// Members named after WIT types and object overrides need no docs. +#pragma warning disable CS1591 + +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Text; + +namespace Hyperlight.Component +{ + /// Calls guest functions by wire name. + public interface IGuestCaller + { + /// + /// Calls with one encoded buffer per + /// parameter and returns the encoded result. + /// + byte[] Call(string function, byte[][] args); + } + + /// Registers host functions by wire name. + public interface IHostFunctionRegistry + { + /// + /// Registers , which receives one + /// encoded buffer per parameter and returns the encoded result. + /// + void Register(string function, int parameterCount, Func implementation); + } + + /// Thrown when encoded data does not match the expected type. + public sealed class WireFormatException : Exception + { + public WireFormatException(string message) : base(message) { } + } + + /// The payload of a result case without a type. + public readonly struct Unit : IEquatable + { + public bool Equals(Unit other) => true; + public override bool Equals(object? obj) => obj is Unit; + public override int GetHashCode() => 0; + public override string ToString() => "()"; + + public static bool operator ==(Unit left, Unit right) => true; + + public static bool operator !=(Unit left, Unit right) => false; + } + + /// + /// A WIT option whose payload is itself an option. + /// Other options map to nullable types. + /// + public readonly struct Option : IEquatable> + { + private readonly T _value; + + private Option(T value) + { + HasValue = true; + _value = value; + } + + public static Option None => default; + + public static Option Some(T value) => new(value); + + public bool HasValue { get; } + + public T Value => HasValue ? _value : throw new InvalidOperationException("option has no value"); + + public bool Equals(Option other) => + HasValue == other.HasValue && (!HasValue || EqualityComparer.Default.Equals(_value, other._value)); + + public override bool Equals(object? obj) => obj is Option other && Equals(other); + + public override int GetHashCode() => HasValue ? HashCode.Combine(true, _value) : 0; + + public override string ToString() => HasValue ? $"Some({_value})" : "None"; + + public static bool operator ==(Option left, Option right) => left.Equals(right); + + public static bool operator !=(Option left, Option right) => !left.Equals(right); + } + + /// A WIT result. + public readonly struct Result : IEquatable> + { + private readonly TOk _ok; + private readonly TErr _err; + + private Result(bool isOk, TOk ok, TErr err) + { + IsOk = isOk; + _ok = ok; + _err = err; + } + + public static Result FromOk(TOk value) => new(true, value, default!); + + public static Result FromErr(TErr value) => new(false, default!, value); + + public bool IsOk { get; } + + public TOk Ok => IsOk ? _ok : throw new InvalidOperationException("result is an error"); + + public TErr Err => IsOk ? throw new InvalidOperationException("result is not an error") : _err; + + public bool Equals(Result other) => + IsOk == other.IsOk && (IsOk + ? EqualityComparer.Default.Equals(_ok, other._ok) + : EqualityComparer.Default.Equals(_err, other._err)); + + public override bool Equals(object? obj) => obj is Result other && Equals(other); + + public override int GetHashCode() => + IsOk ? HashCode.Combine(true, _ok) : HashCode.Combine(false, _err); + + public override string ToString() => IsOk ? $"Ok({_ok})" : $"Err({_err})"; + + public static bool operator ==(Result left, Result right) => left.Equals(right); + + public static bool operator !=(Result left, Result right) => !left.Equals(right); + } + + /// Encodes values in the Hyperlight component wire format. + public sealed class WireWriter + { + // Throws on lone surrogates, which a WIT string cannot hold. + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + + private byte[] _buffer; + private int _length; + + public WireWriter(int capacity = 64) + { + _buffer = new byte[Math.Max(capacity, 1)]; + } + + /// The generated world's resource tables, for writing handles. + public object? Resources { get; init; } + + /// The call that handles written here are given or lent in. + public CallScope? Scope { get; init; } + + public byte[] ToArray() => _buffer.AsSpan(0, _length).ToArray(); + + public void Bool(bool value) => U8(value ? (byte)1 : (byte)0); + + public void U8(byte value) => Grow(1)[0] = value; + + public void S8(sbyte value) => Grow(1)[0] = unchecked((byte)value); + + public void U16(ushort value) => BinaryPrimitives.WriteUInt16LittleEndian(Grow(2), value); + + public void S16(short value) => BinaryPrimitives.WriteInt16LittleEndian(Grow(2), value); + + public void U32(uint value) => BinaryPrimitives.WriteUInt32LittleEndian(Grow(4), value); + + public void S32(int value) => BinaryPrimitives.WriteInt32LittleEndian(Grow(4), value); + + public void U64(ulong value) => BinaryPrimitives.WriteUInt64LittleEndian(Grow(8), value); + + public void S64(long value) => BinaryPrimitives.WriteInt64LittleEndian(Grow(8), value); + + public void F32(float value) => BinaryPrimitives.WriteSingleLittleEndian(Grow(4), value); + + public void F64(double value) => BinaryPrimitives.WriteDoubleLittleEndian(Grow(8), value); + + public void Char(Rune value) => U32((uint)value.Value); + + public void String(string value) + { + ArgumentNullException.ThrowIfNull(value); + var length = StrictUtf8.GetByteCount(value); + Length(length); + StrictUtf8.GetBytes(value, Grow(length)); + } + + /// Writes the element count of a list. + public void Length(int count) => U32(checked((uint)count)); + + /// Writes a list<u8>. + public void Bytes(byte[] value) + { + ArgumentNullException.ThrowIfNull(value); + Length(value.Length); + value.CopyTo(Grow(value.Length)); + } + + /// Writes a list<u8, N>. + public void FixedBytes(byte[] value, int count) + { + CheckFixedLength(value, count); + value.CopyTo(Grow(count)); + } + + /// Writes the low bytes of a flags bitmask. + public void Flags(uint bits, int byteCount) + { + var bytes = Grow(byteCount); + for (var i = 0; i < byteCount; i++) + { + bytes[i] = (byte)(bits >> (8 * i)); + } + } + + /// Throws unless a fixed-length list has elements. + public static void CheckFixedLength(IReadOnlyCollection value, int count) + { + ArgumentNullException.ThrowIfNull(value); + if (value.Count != count) + { + throw new ArgumentException($"fixed-length list needs {count} elements, got {value.Count}"); + } + } + + private Span Grow(int count) + { + if (_buffer.Length - _length < count) + { + Array.Resize(ref _buffer, Math.Max(checked(_length + count), _buffer.Length * 2)); + } + var span = _buffer.AsSpan(_length, count); + _length += count; + return span; + } + } + + /// Decodes values in the Hyperlight component wire format. + public ref struct WireReader + { + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + + private readonly ReadOnlySpan _data; + private int _position; + + public WireReader(ReadOnlySpan data) + { + _data = data; + _position = 0; + } + + /// The generated world's resource tables, for reading handles. + public object? Resources { get; init; } + + public bool Bool() => U8() != 0; + + public byte U8() => Take(1)[0]; + + public sbyte S8() => unchecked((sbyte)Take(1)[0]); + + public ushort U16() => BinaryPrimitives.ReadUInt16LittleEndian(Take(2)); + + public short S16() => BinaryPrimitives.ReadInt16LittleEndian(Take(2)); + + public uint U32() => BinaryPrimitives.ReadUInt32LittleEndian(Take(4)); + + public int S32() => BinaryPrimitives.ReadInt32LittleEndian(Take(4)); + + public ulong U64() => BinaryPrimitives.ReadUInt64LittleEndian(Take(8)); + + public long S64() => BinaryPrimitives.ReadInt64LittleEndian(Take(8)); + + public float F32() => BinaryPrimitives.ReadSingleLittleEndian(Take(4)); + + public double F64() => BinaryPrimitives.ReadDoubleLittleEndian(Take(8)); + + public Rune Char() + { + var value = U32(); + if (!Rune.IsValid(value)) + { + throw new WireFormatException($"invalid char {value:x}"); + } + return new Rune(value); + } + + public string String() + { + var bytes = Take(Length()); + try + { + return StrictUtf8.GetString(bytes); + } + catch (DecoderFallbackException e) + { + throw new WireFormatException($"invalid UTF-8: {e.Message}"); + } + } + + /// Reads the element count of a list. + public int Length() + { + var count = U32(); + // Every element takes at least one byte. + if (count > (uint)(_data.Length - _position)) + { + throw new WireFormatException($"list length {count} exceeds remaining input"); + } + return (int)count; + } + + /// Reads a list<u8>. + public byte[] Bytes() => Take(Length()).ToArray(); + + /// Reads a list<u8, N>. + public byte[] FixedBytes(int count) => Take(count).ToArray(); + + /// Reads a four byte tag that must be below . + public uint Tag32(uint cases) + { + var tag = U32(); + if (tag >= cases) + { + throw new WireFormatException($"invalid tag {tag}"); + } + return tag; + } + + /// + /// Reads a flags bitmask of bytes + /// and keeps the bits in . + /// + public uint Flags(int byteCount, uint mask) + { + var bytes = Take(byteCount); + uint bits = 0; + for (var i = 0; i < byteCount; i++) + { + bits |= (uint)bytes[i] << (8 * i); + } + return bits & mask; + } + + private ReadOnlySpan Take(int count) + { + if ((uint)count > (uint)(_data.Length - _position)) + { + throw new WireFormatException("unexpected end of input"); + } + var span = _data.Slice(_position, count); + _position += count; + return span; + } + } + + /// + /// Host objects of one resource type that the guest refers to by + /// handle. A handle is an index into the table. + /// + public sealed class ResourceTable where T : class + { + private readonly List _entries = new(); + + private sealed class Entry + { + public T? Value; + public bool Lent; + } + + /// + /// Gives to the guest. With + /// , the gift is undone unless the call + /// starts. + /// + public uint Give(T value, CallScope? scope = null) + { + var handle = Add(value, lent: false); + scope?.AddGift(() => Clear(handle)); + return handle; + } + + /// Lends to the guest until ends. + public uint Lend(T value, CallScope scope) + { + ArgumentNullException.ThrowIfNull(scope); + var handle = Add(value, lent: true); + scope.AddLoan(() => Clear(handle)); + return handle; + } + + private void Clear(uint handle) + { + lock (_entries) + { + _entries[(int)handle].Value = null; + } + } + + /// Takes back a value the guest owns. + public T Take(uint handle) + { + lock (_entries) + { + var entry = Get(handle); + if (entry.Lent || entry.Value is null) + { + throw new WireFormatException($"resource handle {handle} is not owned"); + } + var value = entry.Value; + entry.Value = null; + return value; + } + } + + /// Returns the value for a handle the guest lends back to the host. + public T Borrow(uint handle) + { + lock (_entries) + { + return Get(handle).Value ?? throw new WireFormatException($"resource handle {handle} is not valid"); + } + } + + private uint Add(T value, bool lent) + { + ArgumentNullException.ThrowIfNull(value); + lock (_entries) + { + _entries.Add(new Entry { Value = value, Lent = lent }); + return checked((uint)(_entries.Count - 1)); + } + } + + private Entry Get(uint handle) => + handle < (uint)_entries.Count + ? _entries[(int)handle] + : throw new WireFormatException($"invalid resource handle {handle}"); + } + + /// + /// Resources lent to the guest for one call, and resources given to it + /// before the call starts. + /// + public sealed class CallScope + { + private List? _loans; + private List? _gifts; + + internal void AddLoan(Action revoke) => (_loans ??= new()).Add(revoke); + + internal void AddGift(Action undo) => (_gifts ??= new()).Add(undo); + + /// Marks the call as started, which completes the gifts. + public void Commit() => _gifts?.Clear(); + + /// Revokes every loan, and undoes gifts made before . + public void End() + { + Run(_gifts); + Run(_loans); + } + + private static void Run(List? actions) + { + if (actions is null) + { + return; + } + foreach (var action in actions) + { + action(); + } + actions.Clear(); + } + } +} diff --git a/dotnet/src/Hyperlight.Wasm/Hyperlight.Wasm.csproj b/dotnet/src/Hyperlight.Wasm/Hyperlight.Wasm.csproj new file mode 100644 index 00000000..aecbc195 --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm/Hyperlight.Wasm.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + true + true + Runs WebAssembly components in Hyperlight micro VMs. Use with Hyperlight.Wasm.Build to generate typed bindings from WIT. + hyperlight;wasm;webassembly;component;sandbox + + + + + + + + + $(HyperlightNativeDir)$(HyperlightNativePrefix)hyperlight_wasm_capi$(HyperlightNativeSuffix) + + + + + + + + + + diff --git a/dotnet/src/Hyperlight.Wasm/HyperlightException.cs b/dotnet/src/Hyperlight.Wasm/HyperlightException.cs new file mode 100644 index 00000000..b22c4032 --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm/HyperlightException.cs @@ -0,0 +1,39 @@ +// Copyright 2026 The Hyperlight Authors. +// SPDX-License-Identifier: Apache-2.0 + +namespace Hyperlight.Wasm; + +/// The kind of a . +public enum HyperlightErrorKind +{ + /// Any error not covered by another kind. + Other = 0, + + /// + /// The sandbox is poisoned by an interrupted or failed guest call. + /// Restore a snapshot to use it again. + /// + Poisoned = 1, + + /// A guest call was interrupted. + Cancelled = 2, + + /// The operation is not valid in the sandbox's current state. + InvalidState = 3, + + /// An argument is invalid. + InvalidArgument = 4, +} + +/// An error reported by Hyperlight. +public sealed class HyperlightException : Exception +{ + internal HyperlightException(HyperlightErrorKind kind, string message, Exception? inner) + : base(message, inner) + { + Kind = kind; + } + + /// The kind of error. + public HyperlightErrorKind Kind { get; } +} diff --git a/dotnet/src/Hyperlight.Wasm/Native.cs b/dotnet/src/Hyperlight.Wasm/Native.cs new file mode 100644 index 00000000..03049dc2 --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm/Native.cs @@ -0,0 +1,185 @@ +// Copyright 2026 The Hyperlight Authors. +// SPDX-License-Identifier: Apache-2.0 + +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace Hyperlight.Wasm; + +/// Declarations from hyperlight_wasm.h. +internal static unsafe partial class Native +{ + private const string Library = "hyperlight_wasm_capi"; + + [StructLayout(LayoutKind.Sequential)] + internal struct Bytes + { + public byte* Data; + public nuint Len; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct Buf + { + public byte* Data; + public nuint Len; + } + + [LibraryImport(Library)] + internal static partial nint hlw_error_new(byte* message, nuint len); + + [LibraryImport(Library)] + internal static partial uint hlw_error_kind(nint error); + + [LibraryImport(Library)] + internal static partial void hlw_error_message(nint error, out byte* message, out nuint len); + + [LibraryImport(Library)] + internal static partial void hlw_error_delete(nint error); + + [LibraryImport(Library)] + internal static partial void hlw_buf_delete(Buf* buf); + + [LibraryImport(Library)] + internal static partial nint hlw_config_new(); + + [LibraryImport(Library)] + internal static partial void hlw_config_delete(nint config); + + [LibraryImport(Library)] + internal static partial void hlw_config_heap_size(nint config, ulong size); + + [LibraryImport(Library)] + internal static partial void hlw_config_scratch_size(nint config, nuint size); + + [LibraryImport(Library)] + internal static partial void hlw_config_input_size(nint config, nuint size); + + [LibraryImport(Library)] + internal static partial void hlw_config_output_size(nint config, nuint size); + + [LibraryImport(Library)] + internal static partial nint hlw_config_runtime_file(nint config, byte* path, nuint len); + + [LibraryImport(Library)] + internal static partial nint hlw_config_interrupt_signal_offset(nint config, byte offset); + + [LibraryImport(Library)] + internal static partial nint hlw_sandbox_new(nint config, out SandboxHandle sandbox); + + [LibraryImport(Library)] + internal static partial void hlw_sandbox_delete(nint sandbox); + + [LibraryImport(Library)] + internal static partial void hlw_result_set(nint result, byte* data, nuint len); + + [LibraryImport(Library)] + internal static partial nint hlw_sandbox_register( + SandboxHandle sandbox, + byte* name, + nuint nameLen, + nuint nargs, + delegate* unmanaged func, + nint env, + delegate* unmanaged finalizer); + + [LibraryImport(Library)] + internal static partial nint hlw_sandbox_load_file(SandboxHandle sandbox, byte* path, nuint len); + + [LibraryImport(Library)] + internal static partial nint hlw_sandbox_load(SandboxHandle sandbox, byte* data, nuint len); + + [LibraryImport(Library)] + internal static partial nint hlw_sandbox_call( + SandboxHandle sandbox, + byte* name, + nuint nameLen, + Bytes* args, + nuint nargs, + Buf* result); + + [LibraryImport(Library)] + internal static partial nint hlw_sandbox_snapshot(SandboxHandle sandbox, out SnapshotHandle snapshot); + + [LibraryImport(Library)] + internal static partial nint hlw_sandbox_restore(SandboxHandle sandbox, SnapshotHandle snapshot); + + [LibraryImport(Library)] + internal static partial void hlw_snapshot_delete(nint snapshot); + + [LibraryImport(Library)] + internal static partial nint hlw_sandbox_interrupt_handle(SandboxHandle sandbox, out InterruptHandleHandle handle); + + [LibraryImport(Library)] + [return: MarshalAs(UnmanagedType.U1)] + internal static partial bool hlw_interrupt_handle_kill(InterruptHandleHandle handle); + + [LibraryImport(Library)] + internal static partial void hlw_interrupt_handle_delete(nint handle); + + /// Throws if is not null, and frees it. + internal static void Check(nint error, Exception? inner = null) + { + if (error != 0) + { + throw TakeError(error, inner); + } + } + + internal static HyperlightException TakeError(nint error, Exception? inner = null) + { + try + { + hlw_error_message(error, out var message, out var len); + var text = System.Text.Encoding.UTF8.GetString(message, checked((int)len)); + return new HyperlightException((HyperlightErrorKind)hlw_error_kind(error), text, inner); + } + finally + { + hlw_error_delete(error); + } + } + + /// Creates a native error that the library takes ownership of. + internal static nint NewError(string message) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(message); + fixed (byte* p = bytes) + { + return hlw_error_new(p, (nuint)bytes.Length); + } + } +} + +internal sealed class SandboxHandle : SafeHandleZeroOrMinusOneIsInvalid +{ + public SandboxHandle() : base(true) { } + + protected override bool ReleaseHandle() + { + Native.hlw_sandbox_delete(handle); + return true; + } +} + +internal sealed class SnapshotHandle : SafeHandleZeroOrMinusOneIsInvalid +{ + public SnapshotHandle() : base(true) { } + + protected override bool ReleaseHandle() + { + Native.hlw_snapshot_delete(handle); + return true; + } +} + +internal sealed class InterruptHandleHandle : SafeHandleZeroOrMinusOneIsInvalid +{ + public InterruptHandleHandle() : base(true) { } + + protected override bool ReleaseHandle() + { + Native.hlw_interrupt_handle_delete(handle); + return true; + } +} diff --git a/dotnet/src/Hyperlight.Wasm/SandboxOptions.cs b/dotnet/src/Hyperlight.Wasm/SandboxOptions.cs new file mode 100644 index 00000000..0e0a3668 --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm/SandboxOptions.cs @@ -0,0 +1,50 @@ +// Copyright 2026 The Hyperlight Authors. +// SPDX-License-Identifier: Apache-2.0 + +namespace Hyperlight.Wasm; + +/// Settings for a . Unset values use defaults. +public sealed class SandboxOptions +{ + /// + /// The signal offset from SIGRTMIN used unless + /// is set. The .NET runtime uses + /// SIGRTMIN itself to suspend threads. + /// + public const byte DefaultInterruptSignalOffset = 1; + + /// The guest heap size in bytes. Values below the default are ignored. + public ulong? HeapSize { get; set; } + + /// + /// The guest scratch size in bytes. Scratch holds the guest stack and + /// the transport buffers. Values below the default are ignored. + /// + public ulong? ScratchSize { get; set; } + + /// + /// The largest message in bytes the host sends to the guest, such as a + /// guest call or a component loaded from memory. Values below the + /// default are ignored. + /// + public ulong? InputSize { get; set; } + + /// + /// The largest message in bytes the guest sends to the host, such as a + /// host call or a guest result. + /// + public ulong? OutputSize { get; set; } + + /// + /// A guest runtime file built for the component's WIT world by the same + /// hyperlight-wasm version as this package. Unset uses the runtime built + /// into the native library. + /// + public string? RuntimePath { get; set; } + + /// + /// The offset from SIGRTMIN of the signal that interrupts a + /// running guest on Linux. Ignored on Windows. + /// + public byte InterruptSignalOffset { get; set; } = DefaultInterruptSignalOffset; +} diff --git a/dotnet/src/Hyperlight.Wasm/WasmSandbox.cs b/dotnet/src/Hyperlight.Wasm/WasmSandbox.cs new file mode 100644 index 00000000..4e5f4eca --- /dev/null +++ b/dotnet/src/Hyperlight.Wasm/WasmSandbox.cs @@ -0,0 +1,356 @@ +// Copyright 2026 The Hyperlight Authors. +// SPDX-License-Identifier: Apache-2.0 + +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using Hyperlight.Component; + +namespace Hyperlight.Wasm; + +/// +/// A Hyperlight micro VM that runs one WebAssembly component. +/// +/// +/// Register host functions, then load a precompiled component, then call +/// its exports. Generated bindings do this through +/// and . +/// Calls are serialized. Host functions run on the calling thread and must +/// not call back into the same sandbox. +/// +public sealed unsafe class WasmSandbox : IGuestCaller, IHostFunctionRegistry, IDisposable +{ + private readonly SandboxHandle _handle; + private readonly CallState _state = new(); + private readonly object _lock = new(); + private bool _loaded; + private bool _inCall; + private InterruptHandle? _cancelHandle; + + /// Creates a sandbox with , or defaults if null. + /// No hypervisor is available, or the settings are invalid. + public WasmSandbox(SandboxOptions? options = null) + { + options ??= new SandboxOptions(); + var config = Native.hlw_config_new(); + try + { + if (options.HeapSize is { } heap) + { + Native.hlw_config_heap_size(config, heap); + } + if (options.ScratchSize is { } scratch) + { + Native.hlw_config_scratch_size(config, checked((nuint)scratch)); + } + if (options.InputSize is { } input) + { + Native.hlw_config_input_size(config, checked((nuint)input)); + } + if (options.OutputSize is { } output) + { + Native.hlw_config_output_size(config, checked((nuint)output)); + } + if (options.RuntimePath is { } runtime) + { + var path = Encoding.UTF8.GetBytes(Path.GetFullPath(runtime)); + fixed (byte* p = path) + { + Native.Check(Native.hlw_config_runtime_file(config, p, (nuint)path.Length)); + } + } + Native.Check(Native.hlw_config_interrupt_signal_offset(config, options.InterruptSignalOffset)); + Native.Check(Native.hlw_sandbox_new(config, out _handle)); + } + finally + { + Native.hlw_config_delete(config); + } + } + + /// + /// Registers a host function. It receives one encoded buffer per + /// parameter and returns the encoded result. Exceptions it throws fail + /// the guest call, which then throws a + /// with the exception as . + /// + /// A component is already loaded, or the parameter count exceeds 32. + public void Register(string function, int parameterCount, Func implementation) + { + ArgumentNullException.ThrowIfNull(function); + ArgumentNullException.ThrowIfNull(implementation); + ArgumentOutOfRangeException.ThrowIfNegative(parameterCount); + var name = Encoding.UTF8.GetBytes(function); + lock (_lock) + { + ThrowIfDisposed(); + // The native library frees this handle through Release, also when + // registration fails. + var env = GCHandle.ToIntPtr(GCHandle.Alloc(new HostFunction(implementation, _state))); + fixed (byte* p = name) + { + Native.Check(Native.hlw_sandbox_register( + _handle, + p, + (nuint)name.Length, + (nuint)parameterCount, + &Invoke, + env, + &Release)); + } + } + } + + /// + /// Loads a component precompiled with hyperlight-wasm-aot. After a + /// failed load the sandbox can only be disposed. + /// + public void Load(string path) + { + ArgumentNullException.ThrowIfNull(path); + var bytes = Encoding.UTF8.GetBytes(Path.GetFullPath(path)); + lock (_lock) + { + ThrowIfDisposed(); + fixed (byte* p = bytes) + { + Native.Check(Native.hlw_sandbox_load_file(_handle, p, (nuint)bytes.Length)); + } + _loaded = true; + } + } + + /// + /// Loads a component precompiled with hyperlight-wasm-aot from memory. + /// After a failed load the sandbox can only be disposed. + /// + public void Load(ReadOnlySpan component) + { + lock (_lock) + { + ThrowIfDisposed(); + fixed (byte* p = component) + { + Native.Check(Native.hlw_sandbox_load(_handle, p, (nuint)component.Length)); + } + _loaded = true; + } + } + + /// + public byte[] Call(string function, byte[][] args) => Call(function, args, CancellationToken.None); + + /// + /// Calls the guest function with one encoded + /// buffer per parameter and returns the encoded result. + /// + /// + /// Cancelling interrupts the guest + /// and poisons the sandbox. A cancellation just before the guest starts + /// running can be missed. + /// + /// The call was cancelled. + /// The call failed. + public byte[] Call(string function, byte[][] args, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(function); + ArgumentNullException.ThrowIfNull(args); + var name = Encoding.UTF8.GetBytes(function); + var pins = new GCHandle[args.Length]; + var views = new Native.Bytes[args.Length]; + lock (_lock) + { + ThrowIfDisposed(); + if (_inCall) + { + throw new InvalidOperationException("A host function cannot call into its own sandbox."); + } + cancellationToken.ThrowIfCancellationRequested(); + var registration = default(CancellationTokenRegistration); + if (cancellationToken.CanBeCanceled && _loaded) + { + _cancelHandle ??= CreateInterruptHandle(); + registration = cancellationToken.Register(static h => ((InterruptHandle)h!).Kill(), _cancelHandle); + } + _inCall = true; + _state.HostException = null; + try + { + for (var i = 0; i < args.Length; i++) + { + var arg = args[i] ?? throw new ArgumentNullException(nameof(args), $"Argument {i} is null."); + pins[i] = GCHandle.Alloc(arg, GCHandleType.Pinned); + views[i] = new Native.Bytes + { + Data = (byte*)pins[i].AddrOfPinnedObject(), + Len = (nuint)arg.Length, + }; + } + Native.Buf result = default; + nint error; + fixed (byte* p = name) + fixed (Native.Bytes* v = views) + { + error = Native.hlw_sandbox_call(_handle, p, (nuint)name.Length, v, (nuint)views.Length, &result); + } + registration.Dispose(); + if (error != 0) + { + var exception = Native.TakeError(error, _state.HostException); + if (exception.Kind == HyperlightErrorKind.Cancelled && cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(exception.Message, exception, cancellationToken); + } + throw exception; + } + try + { + return new ReadOnlySpan(result.Data, checked((int)result.Len)).ToArray(); + } + finally + { + Native.hlw_buf_delete(&result); + } + } + finally + { + registration.Dispose(); + _inCall = false; + foreach (var pin in pins) + { + if (pin.IsAllocated) + { + pin.Free(); + } + } + } + } + } + + /// Saves the state of the loaded sandbox. + public Snapshot CreateSnapshot() + { + lock (_lock) + { + ThrowIfDisposed(); + Native.Check(Native.hlw_sandbox_snapshot(_handle, out var snapshot)); + return new Snapshot(this, snapshot); + } + } + + /// + /// Restores , which must come from this + /// sandbox. Restoring also recovers a poisoned sandbox. + /// + public void Restore(Snapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + if (snapshot.Owner != this) + { + throw new ArgumentException("The snapshot comes from another sandbox.", nameof(snapshot)); + } + lock (_lock) + { + ThrowIfDisposed(); + Native.Check(Native.hlw_sandbox_restore(_handle, snapshot.Handle)); + } + } + + /// + /// Returns a handle that interrupts guest calls from any thread. The + /// sandbox must be loaded. + /// + public InterruptHandle CreateInterruptHandle() + { + lock (_lock) + { + ThrowIfDisposed(); + Native.Check(Native.hlw_sandbox_interrupt_handle(_handle, out var handle)); + return new InterruptHandle(handle); + } + } + + /// Frees the micro VM and the registered host functions. + public void Dispose() + { + lock (_lock) + { + _cancelHandle?.Dispose(); + _handle.Dispose(); + } + } + + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_handle.IsClosed, this); + + [UnmanagedCallersOnly] + private static nint Invoke(nint env, Native.Bytes* args, nuint nargs, nint result) + { + var function = (HostFunction)GCHandle.FromIntPtr(env).Target!; + try + { + var managed = new byte[checked((int)nargs)][]; + for (var i = 0; i < managed.Length; i++) + { + managed[i] = new ReadOnlySpan(args[i].Data, checked((int)args[i].Len)).ToArray(); + } + var bytes = function.Implementation(managed) ?? []; + fixed (byte* p = bytes) + { + Native.hlw_result_set(result, p, (nuint)bytes.Length); + } + return 0; + } + catch (Exception e) + { + function.State.HostException ??= e; + return Native.NewError($"{e.GetType().FullName}: {e.Message}"); + } + } + + [UnmanagedCallersOnly] + private static void Release(nint env) => GCHandle.FromIntPtr(env).Free(); + + /// State shared with host functions, which must not keep the sandbox alive. + private sealed class CallState + { + public Exception? HostException; + } + + private sealed record HostFunction(Func Implementation, CallState State); +} + +/// A saved sandbox state. +public sealed class Snapshot : IDisposable +{ + internal Snapshot(WasmSandbox owner, SnapshotHandle handle) + { + Owner = owner; + Handle = handle; + } + + internal WasmSandbox Owner { get; } + + internal SnapshotHandle Handle { get; } + + /// + public void Dispose() => Handle.Dispose(); +} + +/// Interrupts guest calls of a sandbox from any thread. +public sealed class InterruptHandle : IDisposable +{ + private readonly InterruptHandleHandle _handle; + + internal InterruptHandle(InterruptHandleHandle handle) => _handle = handle; + + /// + /// Interrupts the running guest call, which then fails with + /// and poisons the sandbox. + /// Blocks until the guest stops. + /// + /// Whether a call was running. + public bool Kill() => Native.hlw_interrupt_handle_kill(_handle); + + /// + public void Dispose() => _handle.Dispose(); +} diff --git a/dotnet/tests/Hyperlight.Wasm.Tests/AssemblyInfo.cs b/dotnet/tests/Hyperlight.Wasm.Tests/AssemblyInfo.cs new file mode 100644 index 00000000..9fd11ac2 --- /dev/null +++ b/dotnet/tests/Hyperlight.Wasm.Tests/AssemblyInfo.cs @@ -0,0 +1,4 @@ +// Copyright 2026 The Hyperlight Authors. +// SPDX-License-Identifier: Apache-2.0 + +[assembly: Xunit.CollectionBehavior(DisableTestParallelization = true)] diff --git a/dotnet/tests/Hyperlight.Wasm.Tests/ComponentTests.cs b/dotnet/tests/Hyperlight.Wasm.Tests/ComponentTests.cs new file mode 100644 index 00000000..ab5be1df --- /dev/null +++ b/dotnet/tests/Hyperlight.Wasm.Tests/ComponentTests.cs @@ -0,0 +1,259 @@ +// Copyright 2026 The Hyperlight Authors. +// SPDX-License-Identifier: Apache-2.0 + +using Hyperlight.Wasm.Tests.Sample; +using Hyperlight.Wasm.Tests.Sample.ComponentSample.Example; +using Xunit; + +namespace Hyperlight.Wasm.Tests; + +public sealed class ComponentTests +{ + private static readonly string Assets = Path.Combine(AppContext.BaseDirectory, "assets"); + private static readonly string Component = Path.Combine(Assets, "component_sample.aot"); + + private sealed class Host : IHost + { + public List Printed { get; } = []; + + public Func Reply { get; set; } = input => $"{input} and the host!"; + + public void Print(string message) => Printed.Add(message); + + public string HostFunction(string input) => Reply(input); + } + + private static SandboxOptions Options() => new() + { + HeapSize = 200_000_000, + ScratchSize = 100 << 20, + // The component is larger than the default input size. + InputSize = 1 << 20, + RuntimePath = Path.Combine(Assets, "component-sample-runtime"), + }; + + private static (WasmSandbox Sandbox, ExampleComponent Component) Load(Host host, bool fromMemory = false) + { + var sandbox = new WasmSandbox(Options()); + ExampleComponent.RegisterImports(sandbox, host); + if (fromMemory) + { + sandbox.Load(File.ReadAllBytes(Component)); + } + else + { + sandbox.Load(Component); + } + return (sandbox, new ExampleComponent(sandbox)); + } + + [Fact] + public void CallsExportsAndImports() + { + var host = new Host(); + var (sandbox, component) = Load(host); + using (sandbox) + { + Assert.Equal(3u, component.Adder.Add(1, 2)); + Assert.Equal("Hello from component and the host!", component.Adder.CallHost("Hello")); + component.Adder.DoSomething(42); + Assert.Equal(["42"], host.Printed); + } + } + + [Fact] + public void LoadsFromMemory() + { + var (sandbox, component) = Load(new Host(), fromMemory: true); + using (sandbox) + { + Assert.Equal(42u, component.Adder.Add(20, 22)); + } + } + + [Fact] + public void HostExceptionsBecomeInnerExceptions() + { + var failure = new InvalidDataException("host failure"); + var host = new Host { Reply = _ => throw failure }; + var (sandbox, component) = Load(host); + using (sandbox) + { + var e = Assert.Throws(() => component.Adder.CallHost("Hello")); + Assert.Same(failure, e.InnerException); + Assert.Contains("host failure", e.Message); + } + } + + [Fact] + public void RestoreRecoversFromTrap() + { + var (sandbox, component) = Load(new Host()); + using (sandbox) + using (var snapshot = sandbox.CreateSnapshot()) + { + // The debug build of the sample traps on overflow. + var trap = Assert.Throws(() => component.Adder.Add(uint.MaxValue, 1)); + Assert.Contains("add_overflow", trap.Message); + Assert.Throws(() => component.Adder.Add(1, 1)); + sandbox.Restore(snapshot); + Assert.Equal(7u, component.Adder.Add(3, 4)); + } + } + + [Fact] + public void RejectsReentrantCalls() + { + var host = new Host(); + var (sandbox, component) = Load(host); + using (sandbox) + { + host.Reply = _ => component.Adder.Add(1, 1).ToString(); + var e = Assert.Throws(() => component.Adder.CallHost("Hello")); + Assert.IsType(e.InnerException); + } + } + + [Fact] + public void CancellationAndInterrupts() + { + var (sandbox, component) = Load(new Host()); + using (sandbox) + using (var handle = sandbox.CreateInterruptHandle()) + { + Assert.False(handle.Kill()); + Assert.Equal(2u, component.Adder.Add(1, 1)); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var args = new[] { new byte[4], new byte[4] }; + Assert.Throws( + () => sandbox.Call("component-sample:example/adder#add", args, cts.Token)); + using var live = new CancellationTokenSource(); + var result = sandbox.Call("component-sample:example/adder#add", args, live.Token); + Assert.Equal(new byte[4], result); + } + } + + [Fact] + public void ValidatesState() + { + var (sandbox, _) = Load(new Host()); + var (other, _) = Load(new Host()); + using (other) + using (var foreign = other.CreateSnapshot()) + { + Assert.Throws(() => sandbox.Restore(foreign)); + var late = Assert.Throws(() => sandbox.Register("late", 0, _ => [])); + Assert.Equal(HyperlightErrorKind.InvalidState, late.Kind); + var missing = Assert.Throws(() => sandbox.Call("missing", [])); + Assert.Contains("missing", missing.Message); + sandbox.Dispose(); + sandbox.Dispose(); + Assert.Throws(() => sandbox.Call("missing", [])); + } + + using var empty = new WasmSandbox(Options()); + var unloaded = Assert.Throws(() => empty.Call("x", [])); + Assert.Equal(HyperlightErrorKind.InvalidState, unloaded.Kind); + } + + [Fact] + public void ReportsMissingRuntime() + { + var options = Options(); + options.RuntimePath = Path.Combine(Assets, "no-such-runtime"); + Assert.Throws(() => new WasmSandbox(options)); + } + + [Fact] + public async Task SurvivesGarbageCollection() + { + // GC.Collect in a host function runs while the guest call is active. + var host = new Host(); + host.Reply = input => + { + GC.Collect(); + return input; + }; + var (sandbox, component) = Load(host); + using (sandbox) + { + using var stop = new CancellationTokenSource(); + var churn = Task.Run(() => + { + while (!stop.IsCancellationRequested) + { + _ = new byte[64 * 1024]; + GC.Collect(0); + } + }); + for (var i = 0; i < 500; i++) + { + Assert.Equal((uint)(2 * i), component.Adder.Add((uint)i, (uint)i)); + Assert.Equal($"{i} from component", component.Adder.CallHost(i.ToString())); + } + stop.Cancel(); + await churn; + } + } +} + +public sealed class SignalTests +{ + private static volatile bool _stop; + + /// + /// The GC suspends a thread spinning in managed code with SIGRTMIN. With + /// interrupt signal offset 0 the sandbox replaces that handler and this + /// test hangs. + /// + [Fact] + public async Task GcSuspendsSpinningThreadAfterSandboxLoads() + { + var assets = Path.Combine(AppContext.BaseDirectory, "assets"); + using var sandbox = new WasmSandbox(new SandboxOptions + { + HeapSize = 200_000_000, + ScratchSize = 100 << 20, + RuntimePath = Path.Combine(assets, "component-sample-runtime"), + }); + ExampleComponent.RegisterImports(sandbox, new NoHost()); + sandbox.Load(Path.Combine(assets, "component_sample.aot")); + + _stop = false; + var spinner = new Thread(Spin) { IsBackground = true }; + spinner.Start(); + try + { + await Task.Run(() => + { + for (var i = 0; i < 20; i++) + { + GC.Collect(); + } + }).WaitAsync(TimeSpan.FromSeconds(30)); + } + finally + { + _stop = true; + } + spinner.Join(); + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static void Spin() + { + // No calls or allocations, so the thread has no GC safe point. + while (!_stop) + { + } + } + + private sealed class NoHost : IHost + { + public void Print(string message) { } + + public string HostFunction(string input) => input; + } +} diff --git a/dotnet/tests/Hyperlight.Wasm.Tests/Hyperlight.Wasm.Tests.csproj b/dotnet/tests/Hyperlight.Wasm.Tests/Hyperlight.Wasm.Tests.csproj new file mode 100644 index 00000000..a85297b0 --- /dev/null +++ b/dotnet/tests/Hyperlight.Wasm.Tests/Hyperlight.Wasm.Tests.csproj @@ -0,0 +1,40 @@ + + + + net8.0 + Major + false + true + Hyperlight.Wasm.Tests + $(MSBuildThisFileDirectory)../../../ + + $(MSBuildThisFileDirectory)../../src/Hyperlight.Wasm.Build/bin/$(Configuration)/net8.0/Hyperlight.Wasm.Build.dll + $(HyperlightNativeDir)$(HyperlightNativePrefix)hyperlight_wasm_bindgen$(HyperlightNativeSuffix) + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/hyperlight_wasm/src/sandbox/sandbox_builder.rs b/src/hyperlight_wasm/src/sandbox/sandbox_builder.rs index 2a4e94b9..f33453a2 100644 --- a/src/hyperlight_wasm/src/sandbox/sandbox_builder.rs +++ b/src/hyperlight_wasm/src/sandbox/sandbox_builder.rs @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +use std::path::PathBuf; + use hyperlight_host::func::HostFunction; use hyperlight_host::sandbox::SandboxConfiguration; use hyperlight_host::{GuestBinary, HyperlightError, Result, is_hypervisor_present}; @@ -31,6 +33,7 @@ pub const MIN_HEAP_SIZE: u64 = 1024 * 1024; pub struct SandboxBuilder { config: SandboxConfiguration, host_print_fn: Option>, + runtime: Option, } impl SandboxBuilder { @@ -44,6 +47,7 @@ impl SandboxBuilder { Self { config, host_print_fn: None, + runtime: None, } } @@ -174,13 +178,38 @@ impl SandboxBuilder { self } + /// Load the guest runtime from `path` instead of using the one built + /// into this crate. + /// + /// The runtime must be built by the same version of hyperlight-wasm. + /// A runtime built with `WIT_WORLD` set can only load components of + /// that world. + pub fn with_runtime_file(mut self, path: impl Into) -> Self { + self.runtime = Some(path.into()); + self + } + + /// Set the offset from `SIGRTMIN` of the signal used to interrupt a + /// running guest. + /// + /// Change it when the host process already uses `SIGRTMIN`. The .NET + /// runtime does, for example. + #[cfg(target_os = "linux")] + pub fn with_interrupt_vcpu_sigrtmin_offset(mut self, offset: u8) -> Result { + self.config.set_interrupt_vcpu_sigrtmin_offset(offset)?; + Ok(self) + } + /// Build the ProtoWasmSandbox pub fn build(self) -> Result { if !is_hypervisor_present() { return Err(HyperlightError::NoHypervisorFound()); } - let guest_binary = GuestBinary::Buffer(super::WASM_RUNTIME.to_vec()); + let guest_binary = match self.runtime { + Some(path) => GuestBinary::FilePath(path), + None => GuestBinary::Buffer(super::WASM_RUNTIME.to_vec()), + }; let mut proto_wasm_sandbox = ProtoWasmSandbox::new(Some(self.config), guest_binary)?; if let Some(host_print_fn) = self.host_print_fn { diff --git a/src/hyperlight_wasm/src/sandbox/wasm_sandbox.rs b/src/hyperlight_wasm/src/sandbox/wasm_sandbox.rs index 4ead108a..193fa2f9 100644 --- a/src/hyperlight_wasm/src/sandbox/wasm_sandbox.rs +++ b/src/hyperlight_wasm/src/sandbox/wasm_sandbox.rs @@ -620,6 +620,36 @@ mod tests { } } + #[test] + fn test_runtime_file() { + let path = env::temp_dir().join(format!("hyperlight-wasm-runtime-{}", std::process::id())); + std::fs::write(&path, &super::super::WASM_RUNTIME[..]).unwrap(); + let sandbox = SandboxBuilder::new().with_runtime_file(&path).build(); + std::fs::remove_file(&path).unwrap(); + let mut sandbox = sandbox.unwrap(); + sandbox + .register( + "GetTimeSinceBootMicrosecond", + get_time_since_boot_microsecond, + ) + .unwrap(); + let helloworld_wasm = get_test_file_path("HelloWorld.aot").unwrap(); + let mut loaded = sandbox + .load_runtime() + .unwrap() + .load_module(helloworld_wasm) + .unwrap(); + let result: i32 = loaded + .call_guest_function("HelloWorld", "Message from Rust Test".to_string()) + .unwrap(); + assert_eq!(result, 0); + + let missing = SandboxBuilder::new() + .with_runtime_file(env::temp_dir().join("hyperlight-wasm-no-such-runtime")) + .build(); + assert!(missing.is_err()); + } + #[test] fn test_load_from_snapshot() { let mut sandbox = SandboxBuilder::new().build().unwrap(); diff --git a/src/hyperlight_wasm_bindgen/Cargo.toml b/src/hyperlight_wasm_bindgen/Cargo.toml new file mode 100644 index 00000000..eaed9051 --- /dev/null +++ b/src/hyperlight_wasm_bindgen/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "hyperlight-wasm-bindgen" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +readme.workspace = true +description = """ +Native library that generates C# host bindings for hyperlight-wasm from WIT. Used by the Hyperlight.Wasm.Build NuGet package at build time. +""" + +[lib] +name = "hyperlight_wasm_bindgen" +crate-type = ["cdylib", "lib"] + +[dependencies] +hyperlight-component-csharp.workspace = true diff --git a/src/hyperlight_wasm_bindgen/src/lib.rs b/src/hyperlight_wasm_bindgen/src/lib.rs new file mode 100644 index 00000000..d7440361 --- /dev/null +++ b/src/hyperlight_wasm_bindgen/src/lib.rs @@ -0,0 +1,165 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! Generates C# host bindings from WIT for the Hyperlight.Wasm.Build +//! MSBuild task, which loads this library at build time. + +use std::any::Any; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::path::Path; + +/// Generates C# bindings for a WIT world. +/// +/// `wit` is the UTF-8 path of a `.wit` file or directory. An empty `world` +/// selects the package's last world. All generated types go under the +/// namespace `ns`. +/// +/// Returns 0 and the C# source in `out`, or 1 and a UTF-8 error message. +/// Free `out` with [`hlw_bindgen_free`]. +/// +/// # Safety +/// Each string pointer must point to its length in readable bytes, or the +/// length must be zero. `out` and `out_len` must be writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_bindgen_csharp( + wit: *const u8, + wit_len: usize, + world: *const u8, + world_len: usize, + ns: *const u8, + ns_len: usize, + out: *mut *mut u8, + out_len: *mut usize, +) -> i32 { + // SAFETY: guaranteed by the caller. + let args = unsafe { (str(wit, wit_len), str(world, world_len), str(ns, ns_len)) }; + let result = match args { + // The generator panics on WIT it cannot read, as the bindgen + // macros do. The message goes back to the build as the error. + (Ok(wit), Ok(world), Ok(ns)) => catch_unwind(AssertUnwindSafe(|| { + let world = (!world.is_empty()).then_some(world); + hyperlight_component_csharp::generate_from_path(Path::new(wit), world, ns) + .map_err(|e| e.to_string()) + })) + .unwrap_or_else(|panic| Err(panic_message(panic.as_ref()))), + _ => Err("argument is not valid UTF-8".to_string()), + }; + let (status, text) = match result { + Ok(source) => (0, source), + Err(message) => (1, message), + }; + let bytes = Box::into_raw(text.into_bytes().into_boxed_slice()); + // SAFETY: guaranteed by the caller. + unsafe { + *out_len = bytes.len(); + *out = bytes.cast(); + } + status +} + +fn panic_message(panic: &(dyn Any + Send)) -> String { + match panic.downcast_ref::() { + Some(message) => message.clone(), + None => panic.downcast_ref::<&str>().map_or_else( + || "WIT binding generation failed".to_string(), + |m| m.to_string(), + ), + } +} + +/// Frees text returned by [`hlw_bindgen_csharp`]. +/// +/// # Safety +/// `data` and `len` must come from one call to [`hlw_bindgen_csharp`], and +/// `data` must not be used afterwards. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_bindgen_free(data: *mut u8, len: usize) { + // SAFETY: guaranteed by the caller. + drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(data, len)) }); +} + +/// # Safety +/// `data` must point to `len` readable bytes, or `len` must be zero. +unsafe fn str<'a>(data: *const u8, len: usize) -> Result<&'a str, std::str::Utf8Error> { + if len == 0 { + return Ok(""); + } + // SAFETY: guaranteed by the caller. + std::str::from_utf8(unsafe { std::slice::from_raw_parts(data, len) }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn generate(wit: &str, world: &str, ns: &str) -> (i32, String) { + let (mut out, mut len) = (std::ptr::null_mut(), 0); + // SAFETY: the strings are readable and the outputs writable. + unsafe { + let status = hlw_bindgen_csharp( + wit.as_ptr(), + wit.len(), + world.as_ptr(), + world.len(), + ns.as_ptr(), + ns.len(), + &mut out, + &mut len, + ); + let text = String::from_utf8(std::slice::from_raw_parts(out, len).to_vec()).unwrap(); + hlw_bindgen_free(out, len); + (status, text) + } + } + + const WIT: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../tests/rust_guests/component_sample/wit/example.wit" + ); + + #[test] + fn generates_selected_world() { + let (status, source) = generate(WIT, "example", "Sample"); + assert_eq!(status, 0, "{source}"); + assert!(source.contains("public sealed class ExampleComponent")); + assert!(source.contains("\"component-sample:example/adder#add\"")); + } + + #[test] + fn reports_errors() { + let (status, message) = generate(WIT, "no-such-world", "Sample"); + assert_eq!(status, 1); + assert!(message.contains("no-such-world"), "{message}"); + let (status, message) = generate("/no/such/file.wit", "", "Sample"); + assert_eq!(status, 1); + assert!(message.contains("/no/such/file.wit"), "{message}"); + let (status, _) = generate(WIT, "example", "Not A Namespace"); + assert_eq!(status, 1); + } + + #[test] + fn dotnet_runtime_source_is_up_to_date() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../dotnet/src/Hyperlight.Wasm/Component/Runtime.cs" + ); + let committed = std::fs::read_to_string(path).unwrap().replace("\r\n", "\n"); + assert!( + committed == hyperlight_component_csharp::RUNTIME_SOURCE, + "copy hyperlight_component_csharp::RUNTIME_SOURCE to {path}" + ); + } +} diff --git a/src/hyperlight_wasm_capi/Cargo.toml b/src/hyperlight_wasm_capi/Cargo.toml new file mode 100644 index 00000000..3a593884 --- /dev/null +++ b/src/hyperlight_wasm_capi/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "hyperlight-wasm-capi" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +readme.workspace = true +description = """ +C API for hyperlight-wasm. Arguments and results are byte buffers encoded as +described in hyperlight's component wire format. +""" + +[lib] +name = "hyperlight_wasm_capi" +crate-type = ["cdylib", "staticlib", "lib"] + +[dependencies] +hyperlight-wasm = { version = "0.15.0", path = "../hyperlight_wasm" } + +[dev-dependencies] +examples_common = { path = "../examples_common" } diff --git a/src/hyperlight_wasm_capi/include/hyperlight_wasm.h b/src/hyperlight_wasm_capi/include/hyperlight_wasm.h new file mode 100644 index 00000000..9c3826b7 --- /dev/null +++ b/src/hyperlight_wasm_capi/include/hyperlight_wasm.h @@ -0,0 +1,242 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * \file hyperlight_wasm.h + * + * C API for running WebAssembly components in Hyperlight sandboxes. + * + * Guest and host functions are identified by their wire names and take one + * encoded byte buffer per parameter. Their results are one encoded byte + * buffer. The encoding and naming are described in hyperlight's + * docs/component-wire-format.md. + * + * Functions that can fail return an `hlw_error *`, which is NULL on success. + * The caller owns a returned error and frees it with `hlw_error_delete`. + * + * A sandbox must not be used from two threads at once. Interrupt handles may + * be used from any thread. + */ + +#ifndef HYPERLIGHT_WASM_H +#define HYPERLIGHT_WASM_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** An error. */ +typedef struct hlw_error hlw_error; + +/** Sandbox settings. */ +typedef struct hlw_config hlw_config; + +/** A sandbox that runs one WebAssembly component. */ +typedef struct hlw_sandbox hlw_sandbox; + +/** A saved sandbox state. */ +typedef struct hlw_snapshot hlw_snapshot; + +/** Interrupts a running guest call. */ +typedef struct hlw_interrupt_handle hlw_interrupt_handle; + +/** The result slot of a host function call. */ +typedef struct hlw_result hlw_result; + +/** Bytes borrowed from the caller. */ +typedef struct hlw_bytes { + const uint8_t *data; + size_t len; +} hlw_bytes; + +/** Bytes owned by the library. Free with `hlw_buf_delete`. */ +typedef struct hlw_buf { + uint8_t *data; + size_t len; +} hlw_buf; + +/** Any error not covered by another kind. */ +#define HLW_ERROR_OTHER 0 +/** + * The sandbox is poisoned by an interrupted or failed guest call. Restore a + * snapshot to use it again. + */ +#define HLW_ERROR_POISONED 1 +/** A guest call was interrupted with `hlw_interrupt_handle_kill`. */ +#define HLW_ERROR_CANCELLED 2 +/** The function is not valid in the sandbox's current state. */ +#define HLW_ERROR_INVALID_STATE 3 +/** An argument is invalid. */ +#define HLW_ERROR_INVALID_ARGUMENT 4 + +/** The largest number of parameters a function can have. */ +#define HLW_MAX_PARAMS 32 + +/** Creates an error of kind `HLW_ERROR_OTHER` from a UTF-8 message. */ +hlw_error *hlw_error_new(const char *message, size_t len); + +/** Returns the kind of `error`, one of the `HLW_ERROR_*` values. */ +uint32_t hlw_error_kind(const hlw_error *error); + +/** + * Returns the UTF-8 message of `error`, which is not NUL-terminated. It is + * valid until `error` is deleted. + */ +void hlw_error_message(const hlw_error *error, const char **message, + size_t *len); + +/** Frees `error`. Accepts NULL. */ +void hlw_error_delete(hlw_error *error); + +/** Frees the bytes of `buf` and clears it. Accepts NULL. */ +void hlw_buf_delete(hlw_buf *buf); + +/** Creates settings with default values. */ +hlw_config *hlw_config_new(void); + +/** Frees `config`. Accepts NULL. */ +void hlw_config_delete(hlw_config *config); + +/** Sets the guest heap size in bytes. Smaller values than the default are ignored. */ +void hlw_config_heap_size(hlw_config *config, uint64_t size); + +/** + * Sets the guest scratch size in bytes. Scratch holds the guest stack and the + * transport buffers. Smaller values than the default are ignored. + */ +void hlw_config_scratch_size(hlw_config *config, size_t size); + +/** + * Sets the largest message in bytes the host can send to the guest, such as + * a guest call or a component loaded with `hlw_sandbox_load`. Smaller values + * than the default are ignored. + */ +void hlw_config_input_size(hlw_config *config, size_t size); + +/** + * Sets the largest message in bytes the guest can send to the host, such as + * a host call or a guest result. + */ +void hlw_config_output_size(hlw_config *config, size_t size); + +/** + * Loads the guest runtime from the UTF-8 path `path` instead of using the + * built-in one. The runtime must be built by the same hyperlight-wasm + * version as this library. + */ +hlw_error *hlw_config_runtime_file(hlw_config *config, const char *path, + size_t len); + +/** + * Sets the offset from SIGRTMIN of the signal that interrupts a running + * guest. Change it when the process already uses SIGRTMIN, as .NET does. + * Has no effect on Windows. + */ +hlw_error *hlw_config_interrupt_signal_offset(hlw_config *config, + uint8_t offset); + +/** + * Creates a sandbox. `config` may be NULL for default settings and is not + * consumed. + */ +hlw_error *hlw_sandbox_new(const hlw_config *config, hlw_sandbox **out); + +/** Frees `sandbox`. Accepts NULL. */ +void hlw_sandbox_delete(hlw_sandbox *sandbox); + +/** + * A host function. It receives `nargs` encoded arguments, which are valid + * until it returns. It stores its encoded result with `hlw_result_set`. + * Leaving the result unset returns an empty buffer. It returns NULL on + * success or an error, which the library frees. + * + * It runs on the thread that called `hlw_sandbox_call`. + */ +typedef hlw_error *(*hlw_host_func)(void *env, const hlw_bytes *args, + size_t nargs, hlw_result *result); + +/** Copies `len` bytes from `data` into `result`. */ +void hlw_result_set(hlw_result *result, const uint8_t *data, size_t len); + +/** + * Registers `func` as the host function `name` with `nargs` parameters. + * Must be called before the component is loaded. `finalizer`, which may be + * NULL, is called with `env` when the sandbox no longer needs it. It is + * also called if registration fails. `env` must be usable from any thread. + */ +hlw_error *hlw_sandbox_register(hlw_sandbox *sandbox, const char *name, + size_t name_len, size_t nargs, + hlw_host_func func, void *env, + void (*finalizer)(void *)); + +/** + * Loads the precompiled component at the UTF-8 path `path`. If loading + * fails, the sandbox can only be deleted. + */ +hlw_error *hlw_sandbox_load_file(hlw_sandbox *sandbox, const char *path, + size_t len); + +/** + * Loads a precompiled component from memory. If loading fails, the sandbox + * can only be deleted. + */ +hlw_error *hlw_sandbox_load(hlw_sandbox *sandbox, const uint8_t *data, + size_t len); + +/** + * Calls the guest function `name` with `nargs` encoded arguments and stores + * its encoded result in `result`. + */ +hlw_error *hlw_sandbox_call(hlw_sandbox *sandbox, const char *name, + size_t name_len, const hlw_bytes *args, + size_t nargs, hlw_buf *result); + +/** Saves the state of a loaded sandbox. */ +hlw_error *hlw_sandbox_snapshot(hlw_sandbox *sandbox, hlw_snapshot **out); + +/** + * Restores `snapshot`, which must come from `sandbox`. Restoring also + * recovers a poisoned sandbox. + */ +hlw_error *hlw_sandbox_restore(hlw_sandbox *sandbox, + const hlw_snapshot *snapshot); + +/** Frees `snapshot`. Accepts NULL. */ +void hlw_snapshot_delete(hlw_snapshot *snapshot); + +/** Returns a handle that interrupts guest calls of a loaded sandbox. */ +hlw_error *hlw_sandbox_interrupt_handle(hlw_sandbox *sandbox, + hlw_interrupt_handle **out); + +/** + * Interrupts the running guest call, which then fails with + * `HLW_ERROR_CANCELLED`. Blocks until the guest stops. Returns whether a + * call was running. + */ +bool hlw_interrupt_handle_kill(const hlw_interrupt_handle *handle); + +/** Frees `handle`. Accepts NULL. */ +void hlw_interrupt_handle_delete(hlw_interrupt_handle *handle); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/hyperlight_wasm_capi/src/lib.rs b/src/hyperlight_wasm_capi/src/lib.rs new file mode 100644 index 00000000..0313837d --- /dev/null +++ b/src/hyperlight_wasm_capi/src/lib.rs @@ -0,0 +1,969 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! C API for hyperlight-wasm, declared in `include/hyperlight_wasm.h`. +//! +//! Guest and host functions take one byte buffer per parameter and return +//! one byte buffer, so the API needs no knowledge of a component's WIT. + +#![deny(missing_docs)] +// Type names match the C header. +#![allow(non_camel_case_types)] + +use std::ffi::c_void; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::path::PathBuf; +use std::sync::Arc; + +use hyperlight_wasm::{ + HyperlightError, InterruptHandle, LoadedWasmSandbox, ProtoWasmSandbox, SandboxBuilder, + Snapshot, new_error, +}; + +/// Any error not covered by another kind. +pub const HLW_ERROR_OTHER: u32 = 0; +/// The sandbox is poisoned by an interrupted or failed guest call. +pub const HLW_ERROR_POISONED: u32 = 1; +/// A guest call was interrupted. +pub const HLW_ERROR_CANCELLED: u32 = 2; +/// The function is not valid in the sandbox's current state. +pub const HLW_ERROR_INVALID_STATE: u32 = 3; +/// An argument is invalid. +pub const HLW_ERROR_INVALID_ARGUMENT: u32 = 4; +/// The largest number of parameters a function can have. +pub const HLW_MAX_PARAMS: usize = 32; + +/// An error returned to C. +pub struct hlw_error { + kind: u32, + message: String, +} + +impl hlw_error { + fn new(kind: u32, message: impl Into) -> *mut hlw_error { + Box::into_raw(Box::new(hlw_error { + kind, + message: message.into(), + })) + } +} + +fn from_hyperlight(error: HyperlightError) -> *mut hlw_error { + let kind = match error { + HyperlightError::PoisonedSandbox => HLW_ERROR_POISONED, + HyperlightError::ExecutionCanceledByHost() => HLW_ERROR_CANCELLED, + _ => HLW_ERROR_OTHER, + }; + hlw_error::new(kind, error.to_string()) +} + +fn ok() -> *mut hlw_error { + std::ptr::null_mut() +} + +/// Bytes borrowed from C. +#[repr(C)] +pub struct hlw_bytes { + /// The first byte. May be null when `len` is zero. + pub data: *const u8, + /// The number of bytes. + pub len: usize, +} + +/// Bytes owned by this library. +#[repr(C)] +pub struct hlw_buf { + /// The first byte. Null when `len` is zero. + pub data: *mut u8, + /// The number of bytes. + pub len: usize, +} + +/// The result slot of a host function call. +#[derive(Default)] +pub struct hlw_result { + data: Vec, +} + +/// Sandbox settings. +#[derive(Default)] +pub struct hlw_config { + builder: SandboxBuilder, +} + +impl hlw_config { + fn update(&mut self, f: impl FnOnce(SandboxBuilder) -> SandboxBuilder) { + self.builder = f(std::mem::take(&mut self.builder)); + } +} + +enum State { + Proto(ProtoWasmSandbox), + Loaded(LoadedWasmSandbox), + /// A load or guest call failed in a way that left no usable sandbox. + Broken, +} + +/// A sandbox that runs one WebAssembly component. +pub struct hlw_sandbox { + state: State, +} + +/// A saved sandbox state. +pub struct hlw_snapshot { + snapshot: Arc, +} + +/// Interrupts a running guest call. +pub struct hlw_interrupt_handle { + handle: Arc, +} + +/// A host function registered from C. +type HostFunc = unsafe extern "C" fn( + env: *mut c_void, + args: *const hlw_bytes, + nargs: usize, + result: *mut hlw_result, +) -> *mut hlw_error; + +struct HostCallback { + func: HostFunc, + env: *mut c_void, + finalizer: Option, +} + +// SAFETY: `hlw_sandbox_register` requires `env` to be usable from any thread. +unsafe impl Send for HostCallback {} +// SAFETY: as for `Send`. Calls are serialized by the sandbox. +unsafe impl Sync for HostCallback {} + +impl HostCallback { + fn invoke(&self, args: Vec>) -> Result, HyperlightError> { + let views = args + .iter() + .map(|a| hlw_bytes { + data: a.as_ptr(), + len: a.len(), + }) + .collect::>(); + let mut result = hlw_result::default(); + // SAFETY: `hlw_sandbox_register` requires `func` to be callable with + // `env`. `views` and `result` outlive the call. + let error = unsafe { (self.func)(self.env, views.as_ptr(), views.len(), &mut result) }; + if error.is_null() { + Ok(result.data) + } else { + // SAFETY: a non-null error comes from `hlw_error_new` and is + // owned by this library from here on. + let error = unsafe { Box::from_raw(error) }; + Err(new_error!("{}", error.message)) + } + } +} + +impl Drop for HostCallback { + fn drop(&mut self) { + if let Some(finalizer) = self.finalizer { + // SAFETY: `hlw_sandbox_register` requires `finalizer` to accept + // `env`. It runs once, when the last reference is dropped. + unsafe { finalizer(self.env) } + } + } +} + +/// A slice from a C pointer and length. The pointer may be null when the +/// length is zero. +/// +/// # Safety +/// If `len` is non-zero, `data` must point to `len` readable values that +/// outlive `'a`. +unsafe fn slice<'a, T>(data: *const T, len: usize) -> &'a [T] { + if len == 0 { + &[] + } else { + // SAFETY: guaranteed by the caller. + unsafe { std::slice::from_raw_parts(data, len) } + } +} + +/// A UTF-8 string from a C pointer and length. +/// +/// # Safety +/// As for [`slice`]. +unsafe fn str<'a>(data: *const u8, len: usize) -> Result<&'a str, *mut hlw_error> { + // SAFETY: guaranteed by the caller. + let bytes = unsafe { slice(data, len) }; + std::str::from_utf8(bytes) + .map_err(|e| hlw_error::new(HLW_ERROR_INVALID_ARGUMENT, format!("invalid UTF-8: {e}"))) +} + +macro_rules! try_c { + ($e:expr) => { + match $e { + Ok(v) => v, + Err(e) => return e, + } + }; +} + +/// Expands `$m!(a0 a1 ...)` for every argument count from 0 to +/// [`HLW_MAX_PARAMS`]. +macro_rules! for_each_arity { + ($m:ident) => { + for_each_arity!(@ $m [] [ + a0 a1 a2 a3 a4 a5 a6 a7 a8 a9 a10 a11 a12 a13 a14 a15 + a16 a17 a18 a19 a20 a21 a22 a23 a24 a25 a26 a27 a28 a29 a30 a31 + ]); + }; + (@ $m:ident [$($done:ident)*] []) => { + $m!($($done)*); + }; + (@ $m:ident [$($done:ident)*] [$next:ident $($rest:ident)*]) => { + $m!($($done)*); + for_each_arity!(@ $m [$($done)* $next] [$($rest)*]); + }; +} + +macro_rules! count { + ($($a:ident)*) => { 0usize $(+ { let _ = stringify!($a); 1 })* }; +} + +/// Creates an error of kind `HLW_ERROR_OTHER` from a UTF-8 message. +/// +/// # Safety +/// `message` must point to `len` readable bytes, or `len` must be zero. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_error_new(message: *const u8, len: usize) -> *mut hlw_error { + // SAFETY: guaranteed by the caller. + let bytes = unsafe { slice(message, len) }; + hlw_error::new(HLW_ERROR_OTHER, String::from_utf8_lossy(bytes)) +} + +/// Returns the kind of `error`. +/// +/// # Safety +/// `error` must be a live error from this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_error_kind(error: *const hlw_error) -> u32 { + // SAFETY: guaranteed by the caller. + unsafe { (*error).kind } +} + +/// Returns the message of `error`. +/// +/// # Safety +/// `error` must be a live error from this library. `message` and `len` must +/// be writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_error_message( + error: *const hlw_error, + message: *mut *const u8, + len: *mut usize, +) { + // SAFETY: guaranteed by the caller. + unsafe { + let error = &*error; + *message = error.message.as_ptr(); + *len = error.message.len(); + } +} + +/// Frees `error`. +/// +/// # Safety +/// `error` must be null or an error from this library that is not used +/// afterwards. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_error_delete(error: *mut hlw_error) { + if !error.is_null() { + // SAFETY: guaranteed by the caller. + drop(unsafe { Box::from_raw(error) }); + } +} + +/// Frees the bytes of `buf` and clears it. +/// +/// # Safety +/// `buf` must be null or hold bytes returned by this library, or be empty. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_buf_delete(buf: *mut hlw_buf) { + // SAFETY: guaranteed by the caller. + let Some(buf) = (unsafe { buf.as_mut() }) else { + return; + }; + if !buf.data.is_null() { + // SAFETY: the bytes come from `into_buf`, which leaks a boxed slice + // of this length. + drop(unsafe { Box::from_raw(std::ptr::slice_from_raw_parts_mut(buf.data, buf.len)) }); + } + buf.data = std::ptr::null_mut(); + buf.len = 0; +} + +fn into_buf(bytes: Vec) -> hlw_buf { + let len = bytes.len(); + if len == 0 { + return hlw_buf { + data: std::ptr::null_mut(), + len, + }; + } + let data = Box::into_raw(bytes.into_boxed_slice()).cast::(); + hlw_buf { data, len } +} + +/// Creates settings with default values. +#[unsafe(no_mangle)] +pub extern "C" fn hlw_config_new() -> *mut hlw_config { + Box::into_raw(Box::default()) +} + +/// Frees `config`. +/// +/// # Safety +/// `config` must be null or a config from this library that is not used +/// afterwards. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_config_delete(config: *mut hlw_config) { + if !config.is_null() { + // SAFETY: guaranteed by the caller. + drop(unsafe { Box::from_raw(config) }); + } +} + +/// Sets the guest heap size in bytes. +/// +/// # Safety +/// `config` must be a live config from this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_config_heap_size(config: *mut hlw_config, size: u64) { + // SAFETY: guaranteed by the caller. + unsafe { &mut *config }.update(|b| b.with_guest_heap_size(size)); +} + +/// Sets the guest scratch size in bytes. +/// +/// # Safety +/// `config` must be a live config from this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_config_scratch_size(config: *mut hlw_config, size: usize) { + // SAFETY: guaranteed by the caller. + unsafe { &mut *config }.update(|b| b.with_guest_scratch_size(size)); +} + +/// Guest page size in bytes. +const PAGE_SIZE: usize = 4096; + +/// Largest message the host can send to the guest by default. +const DEFAULT_INPUT_SIZE: usize = 192 * 1024; + +/// Sets the largest message the host can send to the guest. Values below +/// the default are ignored. +/// +/// # Safety +/// `config` must be a live config from this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_config_input_size(config: *mut hlw_config, size: usize) { + if size <= DEFAULT_INPUT_SIZE { + return; + } + // Split a message over a few buffers so the spare buffer hyperlight + // keeps for a control call stays small. Two more buffers hold that + // spare and the message header. + const MESSAGE_BUFFERS: usize = 8; + let pages = size.div_ceil(MESSAGE_BUFFERS).div_ceil(PAGE_SIZE); + // SAFETY: guaranteed by the caller. + unsafe { &mut *config }.update(|b| { + b.with_h2g_buffer_size(pages * PAGE_SIZE) + .with_h2g_pool_pages((MESSAGE_BUFFERS + 2) * pages) + }); +} + +/// Sets the largest message the guest can send to the host. +/// +/// # Safety +/// `config` must be a live config from this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_config_output_size(config: *mut hlw_config, size: usize) { + // SAFETY: guaranteed by the caller. + unsafe { &mut *config }.update(|b| { + // One small-buffer page, plus a request and its reply. + b.with_g2h_buffer_size(size) + .with_g2h_pool_pages(1 + 2 * size.div_ceil(PAGE_SIZE)) + }); +} + +/// Loads the guest runtime from a file. +/// +/// # Safety +/// `config` must be a live config from this library. `path` must point to +/// `len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_config_runtime_file( + config: *mut hlw_config, + path: *const u8, + len: usize, +) -> *mut hlw_error { + // SAFETY: guaranteed by the caller. + let path = PathBuf::from(try_c!(unsafe { str(path, len) })); + // SAFETY: guaranteed by the caller. + unsafe { &mut *config }.update(|b| b.with_runtime_file(path)); + ok() +} + +/// Sets the offset from `SIGRTMIN` of the interrupt signal. +/// +/// # Safety +/// `config` must be a live config from this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_config_interrupt_signal_offset( + config: *mut hlw_config, + offset: u8, +) -> *mut hlw_error { + #[cfg(target_os = "linux")] + { + // SAFETY: guaranteed by the caller. + let config = unsafe { &mut *config }; + match config + .builder + .clone() + .with_interrupt_vcpu_sigrtmin_offset(offset) + { + Ok(builder) => config.builder = builder, + Err(e) => return hlw_error::new(HLW_ERROR_INVALID_ARGUMENT, e.to_string()), + } + } + #[cfg(not(target_os = "linux"))] + let _ = (config, offset); + ok() +} + +/// Creates a sandbox. +/// +/// # Safety +/// `config` must be null or a live config from this library. `out` must be +/// writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_sandbox_new( + config: *const hlw_config, + out: *mut *mut hlw_sandbox, +) -> *mut hlw_error { + // SAFETY: guaranteed by the caller. + let builder = unsafe { config.as_ref() } + .map(|c| c.builder.clone()) + .unwrap_or_default(); + let proto = try_c!(builder.build().map_err(from_hyperlight)); + // SAFETY: guaranteed by the caller. + unsafe { + *out = Box::into_raw(Box::new(hlw_sandbox { + state: State::Proto(proto), + })); + } + ok() +} + +/// Frees `sandbox`. +/// +/// # Safety +/// `sandbox` must be null or a sandbox from this library that is not used +/// afterwards. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_sandbox_delete(sandbox: *mut hlw_sandbox) { + if !sandbox.is_null() { + // SAFETY: guaranteed by the caller. + drop(unsafe { Box::from_raw(sandbox) }); + } +} + +/// Copies `len` bytes from `data` into `result`. +/// +/// # Safety +/// `result` must be the result slot passed to the running host function. +/// `data` must point to `len` readable bytes, or `len` must be zero. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_result_set(result: *mut hlw_result, data: *const u8, len: usize) { + // SAFETY: guaranteed by the caller. + unsafe { (*result).data = slice(data, len).to_vec() }; +} + +fn invalid_state(what: &str) -> *mut hlw_error { + hlw_error::new(HLW_ERROR_INVALID_STATE, what) +} + +/// Registers a host function. +/// +/// # Safety +/// `sandbox` must be a live sandbox from this library. `name` must point to +/// `name_len` readable bytes. `func` must be safe to call with `env` from +/// any thread until `finalizer` runs, and `finalizer` must be null or safe +/// to call once with `env`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_sandbox_register( + sandbox: *mut hlw_sandbox, + name: *const u8, + name_len: usize, + nargs: usize, + func: HostFunc, + env: *mut c_void, + finalizer: Option, +) -> *mut hlw_error { + let callback = Arc::new(HostCallback { + func, + env, + finalizer, + }); + // SAFETY: guaranteed by the caller. + let name = try_c!(unsafe { str(name, name_len) }); + // SAFETY: guaranteed by the caller. + let State::Proto(proto) = &mut unsafe { &mut *sandbox }.state else { + return invalid_state("host functions must be registered before loading"); + }; + + macro_rules! register { + ($($a:ident)*) => { + if nargs == count!($($a)*) { + let callback = callback.clone(); + return match proto.register(name, move |$($a: Vec),*| callback.invoke(vec![$($a),*])) { + Ok(()) => ok(), + Err(e) => from_hyperlight(e), + }; + } + }; + } + for_each_arity!(register); + hlw_error::new( + HLW_ERROR_INVALID_ARGUMENT, + format!("{nargs} parameters exceed the limit of {HLW_MAX_PARAMS}"), + ) +} + +fn load( + sandbox: &mut hlw_sandbox, + load: impl FnOnce(hyperlight_wasm::WasmSandbox) -> hyperlight_wasm::Result, +) -> *mut hlw_error { + let State::Proto(proto) = std::mem::replace(&mut sandbox.state, State::Broken) else { + return invalid_state("a component is already loaded"); + }; + match proto.load_runtime().and_then(load) { + Ok(loaded) => { + sandbox.state = State::Loaded(loaded); + ok() + } + Err(e) => from_hyperlight(e), + } +} + +/// Loads a precompiled component from a file. +/// +/// # Safety +/// `sandbox` must be a live sandbox from this library. `path` must point to +/// `len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_sandbox_load_file( + sandbox: *mut hlw_sandbox, + path: *const u8, + len: usize, +) -> *mut hlw_error { + // SAFETY: guaranteed by the caller. + let path = try_c!(unsafe { str(path, len) }); + // SAFETY: guaranteed by the caller. + load(unsafe { &mut *sandbox }, |s| s.load_module(path)) +} + +/// Loads a precompiled component from memory. +/// +/// # Safety +/// `sandbox` must be a live sandbox from this library. `data` must point to +/// `len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_sandbox_load( + sandbox: *mut hlw_sandbox, + data: *const u8, + len: usize, +) -> *mut hlw_error { + // SAFETY: guaranteed by the caller. + let data = unsafe { slice(data, len) }; + // SAFETY: guaranteed by the caller. + load(unsafe { &mut *sandbox }, |s| { + s.load_module_from_buffer(data) + }) +} + +fn loaded(sandbox: &mut hlw_sandbox) -> Result<&mut LoadedWasmSandbox, *mut hlw_error> { + match &mut sandbox.state { + State::Loaded(loaded) => Ok(loaded), + State::Proto(_) => Err(invalid_state("no component is loaded")), + State::Broken => Err(invalid_state("the sandbox failed and can only be deleted")), + } +} + +fn call( + loaded: &mut LoadedWasmSandbox, + name: &str, + mut args: Vec>, +) -> Result, *mut hlw_error> { + macro_rules! call { + ($($a:ident)*) => {{ + const N: usize = count!($($a)*); + if args.len() == N { + return match <[Vec; N]>::try_from(std::mem::take(&mut args)) { + Ok([$($a),*]) => loaded + .call_guest_function::>(name, ($($a,)*)) + .map_err(from_hyperlight), + Err(_) => unreachable!("length checked"), + }; + } + }}; + } + for_each_arity!(call); + Err(hlw_error::new( + HLW_ERROR_INVALID_ARGUMENT, + format!( + "{} arguments exceed the limit of {HLW_MAX_PARAMS}", + args.len() + ), + )) +} + +/// Calls a guest function. +/// +/// # Safety +/// `sandbox` must be a live sandbox from this library. `name` must point to +/// `name_len` readable bytes. `args` must point to `nargs` values whose +/// bytes are readable. `result` must be writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_sandbox_call( + sandbox: *mut hlw_sandbox, + name: *const u8, + name_len: usize, + args: *const hlw_bytes, + nargs: usize, + result: *mut hlw_buf, +) -> *mut hlw_error { + // SAFETY: guaranteed by the caller. + let name = try_c!(unsafe { str(name, name_len) }); + // SAFETY: guaranteed by the caller. + let args = unsafe { slice(args, nargs) } + .iter() + // SAFETY: guaranteed by the caller. + .map(|a| unsafe { slice(a.data, a.len) }.to_vec()) + .collect(); + // SAFETY: guaranteed by the caller. + let sandbox = unsafe { &mut *sandbox }; + let loaded = try_c!(loaded(sandbox)); + // A panic in the guest call leaves the sandbox in an unknown state. + match catch_unwind(AssertUnwindSafe(|| call(loaded, name, args))) { + Ok(Ok(bytes)) => { + // SAFETY: guaranteed by the caller. + unsafe { *result = into_buf(bytes) }; + ok() + } + Ok(Err(e)) => e, + Err(panic) => { + sandbox.state = State::Broken; + let message = panic + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| panic.downcast_ref::().cloned()) + .unwrap_or_default(); + hlw_error::new(HLW_ERROR_OTHER, format!("guest call panicked: {message}")) + } + } +} + +/// Saves the state of a loaded sandbox. +/// +/// # Safety +/// `sandbox` must be a live sandbox from this library. `out` must be +/// writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_sandbox_snapshot( + sandbox: *mut hlw_sandbox, + out: *mut *mut hlw_snapshot, +) -> *mut hlw_error { + // SAFETY: guaranteed by the caller. + let loaded = try_c!(loaded(unsafe { &mut *sandbox })); + let snapshot = try_c!(loaded.snapshot().map_err(from_hyperlight)); + // SAFETY: guaranteed by the caller. + unsafe { *out = Box::into_raw(Box::new(hlw_snapshot { snapshot })) }; + ok() +} + +/// Restores a snapshot. +/// +/// # Safety +/// `sandbox` and `snapshot` must be live objects from this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_sandbox_restore( + sandbox: *mut hlw_sandbox, + snapshot: *const hlw_snapshot, +) -> *mut hlw_error { + // SAFETY: guaranteed by the caller. + let loaded = try_c!(loaded(unsafe { &mut *sandbox })); + // SAFETY: guaranteed by the caller. + let snapshot = unsafe { &*snapshot }.snapshot.clone(); + match loaded.restore(snapshot) { + Ok(()) => ok(), + Err(e) => from_hyperlight(e), + } +} + +/// Frees `snapshot`. +/// +/// # Safety +/// `snapshot` must be null or a snapshot from this library that is not used +/// afterwards. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_snapshot_delete(snapshot: *mut hlw_snapshot) { + if !snapshot.is_null() { + // SAFETY: guaranteed by the caller. + drop(unsafe { Box::from_raw(snapshot) }); + } +} + +/// Returns a handle that interrupts guest calls. +/// +/// # Safety +/// `sandbox` must be a live sandbox from this library. `out` must be +/// writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_sandbox_interrupt_handle( + sandbox: *mut hlw_sandbox, + out: *mut *mut hlw_interrupt_handle, +) -> *mut hlw_error { + // SAFETY: guaranteed by the caller. + let loaded = try_c!(loaded(unsafe { &mut *sandbox })); + let handle = try_c!(loaded.interrupt_handle().map_err(from_hyperlight)); + // SAFETY: guaranteed by the caller. + unsafe { *out = Box::into_raw(Box::new(hlw_interrupt_handle { handle })) }; + ok() +} + +/// Interrupts the running guest call. +/// +/// # Safety +/// `handle` must be a live interrupt handle from this library. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_interrupt_handle_kill(handle: *const hlw_interrupt_handle) -> bool { + // SAFETY: guaranteed by the caller. + unsafe { &*handle }.handle.kill() +} + +/// Frees `handle`. +/// +/// # Safety +/// `handle` must be null or a handle from this library that is not used +/// afterwards. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hlw_interrupt_handle_delete(handle: *mut hlw_interrupt_handle) { + if !handle.is_null() { + // SAFETY: guaranteed by the caller. + drop(unsafe { Box::from_raw(handle) }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + unsafe extern "C" fn unused( + _env: *mut c_void, + _args: *const hlw_bytes, + _nargs: usize, + _result: *mut hlw_result, + ) -> *mut hlw_error { + unreachable!() + } + + fn take(error: *mut hlw_error) -> Option<(u32, String)> { + if error.is_null() { + return None; + } + // SAFETY: errors returned by this library are owned by the caller. + let error = unsafe { Box::from_raw(error) }; + Some((error.kind, error.message)) + } + + fn sandbox() -> *mut hlw_sandbox { + let mut sandbox = std::ptr::null_mut(); + // SAFETY: a null config selects defaults and `sandbox` is writable. + assert_eq!( + take(unsafe { hlw_sandbox_new(std::ptr::null(), &mut sandbox) }), + None + ); + sandbox + } + + #[test] + fn errors() { + let message = "bad \u{1F388}"; + // SAFETY: `message` is readable for its length. + let error = unsafe { hlw_error_new(message.as_ptr(), message.len()) }; + let (mut ptr, mut len) = (std::ptr::null(), 0); + // SAFETY: `error` is live and the outputs are writable. + unsafe { + assert_eq!(hlw_error_kind(error), HLW_ERROR_OTHER); + hlw_error_message(error, &mut ptr, &mut len); + assert_eq!(slice(ptr, len), message.as_bytes()); + hlw_error_delete(error); + hlw_error_delete(std::ptr::null_mut()); + } + + let kind = |e| take(from_hyperlight(e)).unwrap().0; + assert_eq!(kind(HyperlightError::PoisonedSandbox), HLW_ERROR_POISONED); + assert_eq!( + kind(HyperlightError::ExecutionCanceledByHost()), + HLW_ERROR_CANCELLED + ); + assert_eq!(kind(new_error!("x")), HLW_ERROR_OTHER); + } + + #[test] + fn buffers() { + let mut result = hlw_result::default(); + // SAFETY: the data is readable and `result` is a live result slot. + unsafe { + hlw_result_set(&mut result, [1u8, 2, 3].as_ptr(), 3); + assert_eq!(result.data, [1, 2, 3]); + hlw_result_set(&mut result, std::ptr::null(), 0); + assert!(result.data.is_empty()); + } + + let mut buf = into_buf(vec![4, 5]); + // SAFETY: `buf` holds bytes from `into_buf`. + unsafe { + assert_eq!(slice(buf.data, buf.len), [4, 5]); + hlw_buf_delete(&mut buf); + } + assert!(buf.data.is_null() && buf.len == 0); + let mut empty = into_buf(Vec::new()); + assert!(empty.data.is_null()); + // SAFETY: an empty buffer and null are accepted. + unsafe { + hlw_buf_delete(&mut empty); + hlw_buf_delete(std::ptr::null_mut()); + } + } + + #[test] + fn config() { + let config = hlw_config_new(); + let path = "/no/such/runtime"; + let invalid = [0xffu8]; + // SAFETY: `config` is live and the strings are readable. + unsafe { + hlw_config_heap_size(config, 8 << 20); + hlw_config_scratch_size(config, 8 << 20); + hlw_config_input_size(config, 1 << 20); + hlw_config_output_size(config, 1 << 20); + assert_eq!(take(hlw_config_interrupt_signal_offset(config, 1)), None); + #[cfg(target_os = "linux")] + assert_eq!( + take(hlw_config_interrupt_signal_offset(config, u8::MAX)).map(|e| e.0), + Some(HLW_ERROR_INVALID_ARGUMENT) + ); + assert_eq!( + take(hlw_config_runtime_file(config, invalid.as_ptr(), 1)).map(|e| e.0), + Some(HLW_ERROR_INVALID_ARGUMENT) + ); + assert_eq!( + take(hlw_config_runtime_file(config, path.as_ptr(), path.len())), + None + ); + let mut sandbox = std::ptr::null_mut(); + assert!(take(hlw_sandbox_new(config, &mut sandbox)).is_some()); + assert!(sandbox.is_null()); + hlw_config_delete(config); + hlw_config_delete(std::ptr::null_mut()); + } + } + + static FINALIZED: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + + unsafe extern "C" fn finalize(_env: *mut c_void) { + FINALIZED.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + + #[test] + fn invalid_calls() { + let sandbox = sandbox(); + let name = "f"; + let mut snapshot = std::ptr::null_mut(); + let mut handle = std::ptr::null_mut(); + let mut result = hlw_buf { + data: std::ptr::null_mut(), + len: 0, + }; + // SAFETY: `sandbox` is live, the strings are readable and the outputs + // are writable. + unsafe { + let state = |e| take(e).map(|e: (u32, String)| e.0); + let call = + hlw_sandbox_call(sandbox, name.as_ptr(), 1, std::ptr::null(), 0, &mut result); + assert_eq!(state(call), Some(HLW_ERROR_INVALID_STATE)); + assert_eq!( + state(hlw_sandbox_snapshot(sandbox, &mut snapshot)), + Some(HLW_ERROR_INVALID_STATE) + ); + assert_eq!( + state(hlw_sandbox_interrupt_handle(sandbox, &mut handle)), + Some(HLW_ERROR_INVALID_STATE) + ); + + let before = FINALIZED.load(std::sync::atomic::Ordering::SeqCst); + let too_many = hlw_sandbox_register( + sandbox, + name.as_ptr(), + 1, + HLW_MAX_PARAMS + 1, + unused, + std::ptr::null_mut(), + Some(finalize), + ); + assert_eq!(state(too_many), Some(HLW_ERROR_INVALID_ARGUMENT)); + let bad_name = hlw_sandbox_register( + sandbox, + [0xffu8].as_ptr(), + 1, + 0, + unused, + std::ptr::null_mut(), + Some(finalize), + ); + assert_eq!(state(bad_name), Some(HLW_ERROR_INVALID_ARGUMENT)); + // The finalizer runs when registration fails. + assert_eq!( + FINALIZED.load(std::sync::atomic::Ordering::SeqCst), + before + 2 + ); + + let garbage = [0u8; 16]; + let load = hlw_sandbox_load(sandbox, garbage.as_ptr(), garbage.len()); + assert!(take(load).is_some()); + // A failed load leaves only deletion. + let reload = hlw_sandbox_load(sandbox, garbage.as_ptr(), garbage.len()); + assert_eq!(state(reload), Some(HLW_ERROR_INVALID_STATE)); + let call = + hlw_sandbox_call(sandbox, name.as_ptr(), 1, std::ptr::null(), 0, &mut result); + assert_eq!(state(call), Some(HLW_ERROR_INVALID_STATE)); + hlw_sandbox_delete(sandbox); + hlw_sandbox_delete(std::ptr::null_mut()); + hlw_snapshot_delete(std::ptr::null_mut()); + hlw_interrupt_handle_delete(std::ptr::null_mut()); + } + } +} diff --git a/src/hyperlight_wasm_capi/tests/component.rs b/src/hyperlight_wasm_capi/tests/component.rs new file mode 100644 index 00000000..ae935431 --- /dev/null +++ b/src/hyperlight_wasm_capi/tests/component.rs @@ -0,0 +1,306 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! Runs the `example` world of `component_sample` through the C API. +//! +//! The guest runtime must be built for that world, so run with `WIT_WORLD` +//! set to `src/tests/rust_guests/component_sample/wit/component-world.wasm` +//! and `--include-ignored`, as `just examples-components` does. + +use std::ffi::c_void; +use std::sync::{Arc, Mutex}; + +use hyperlight_wasm_capi::*; + +type HostFunc = + unsafe extern "C" fn(*mut c_void, *const hlw_bytes, usize, *mut hlw_result) -> *mut hlw_error; + +const ADD: &str = "component-sample:example/adder#add"; +const CALL_HOST: &str = "component-sample:example/adder#call-host"; +const DO_SOMETHING: &str = "component-sample:example/adder#do-something"; +const PRINT: &str = "component-sample:example/host#print"; +const HOST_FUNCTION: &str = "component-sample:example/host#host-function"; + +fn encode_string(s: &str) -> Vec { + [&(s.len() as u32).to_le_bytes()[..], s.as_bytes()].concat() +} + +fn decode_string(bytes: &[u8]) -> String { + let len = u32::from_le_bytes(bytes[..4].try_into().unwrap()) as usize; + assert_eq!(bytes.len(), 4 + len); + String::from_utf8(bytes[4..].to_vec()).unwrap() +} + +#[derive(Debug)] +struct Error { + kind: u32, + message: String, +} + +fn check(error: *mut hlw_error) -> Result<(), Error> { + if error.is_null() { + return Ok(()); + } + let (mut ptr, mut len) = (std::ptr::null(), 0); + // SAFETY: `error` is a live error owned by the caller. + unsafe { + hlw_error_message(error, &mut ptr, &mut len); + let message = String::from_utf8_lossy(std::slice::from_raw_parts(ptr, len)).into_owned(); + let kind = hlw_error_kind(error); + hlw_error_delete(error); + Err(Error { kind, message }) + } +} + +#[track_caller] +fn ok(error: *mut hlw_error) { + if let Err(e) = check(error) { + panic!("error {}: {}", e.kind, e.message); + } +} + +/// Host state shared with the callbacks through `env`. +#[derive(Default)] +struct Host { + printed: Mutex>, + fail: bool, +} + +unsafe extern "C" fn print( + env: *mut c_void, + args: *const hlw_bytes, + nargs: usize, + _result: *mut hlw_result, +) -> *mut hlw_error { + // SAFETY: `env` is the `Host` passed at registration and the arguments + // are valid for the call. + unsafe { + let host = &*(env as *const Host); + assert_eq!(nargs, 1); + let arg = std::slice::from_raw_parts((*args).data, (*args).len); + host.printed.lock().unwrap().push(decode_string(arg)); + } + std::ptr::null_mut() +} + +unsafe extern "C" fn host_function( + env: *mut c_void, + args: *const hlw_bytes, + nargs: usize, + result: *mut hlw_result, +) -> *mut hlw_error { + // SAFETY: as for `print`. + unsafe { + let host = &*(env as *const Host); + if host.fail { + let message = "host failure"; + return hlw_error_new(message.as_ptr(), message.len()); + } + assert_eq!(nargs, 1); + let input = decode_string(std::slice::from_raw_parts((*args).data, (*args).len)); + let output = encode_string(&format!("{input} and the host!")); + hlw_result_set(result, output.as_ptr(), output.len()); + } + std::ptr::null_mut() +} + +unsafe extern "C" fn drop_host(env: *mut c_void) { + // SAFETY: `env` is a reference from `Arc::into_raw` given to one + // registration, and the sandbox calls this once after its last use. + drop(unsafe { Arc::from_raw(env as *const Host) }); +} + +struct Sandbox(*mut hlw_sandbox); + +impl Sandbox { + fn new(host: &Arc, from_memory: bool) -> Sandbox { + let path = examples_common::get_wasm_module_path("component_sample.aot").unwrap(); + // SAFETY: every pointer passed is live and every string readable. + unsafe { + let config = hlw_config_new(); + hlw_config_heap_size(config, 200_000_000); + hlw_config_scratch_size(config, 100 << 20); + hlw_config_input_size(config, 1 << 20); + let mut sandbox = std::ptr::null_mut(); + ok(hlw_sandbox_new(config, &mut sandbox)); + hlw_config_delete(config); + + for (name, func) in [(PRINT, print as HostFunc), (HOST_FUNCTION, host_function)] { + let env = Arc::into_raw(host.clone()) as *mut c_void; + ok(hlw_sandbox_register( + sandbox, + name.as_ptr(), + name.len(), + 1, + func, + env, + Some(drop_host), + )); + } + if from_memory { + let bytes = std::fs::read(&path).unwrap(); + ok(hlw_sandbox_load(sandbox, bytes.as_ptr(), bytes.len())); + } else { + ok(hlw_sandbox_load_file(sandbox, path.as_ptr(), path.len())); + } + Sandbox(sandbox) + } + } + + fn call(&self, name: &str, args: &[Vec]) -> Result, Error> { + let views = args + .iter() + .map(|a| hlw_bytes { + data: a.as_ptr(), + len: a.len(), + }) + .collect::>(); + let mut buf = hlw_buf { + data: std::ptr::null_mut(), + len: 0, + }; + // SAFETY: the sandbox is live, `views` outlives the call and `buf` + // is writable. + unsafe { + check(hlw_sandbox_call( + self.0, + name.as_ptr(), + name.len(), + views.as_ptr(), + views.len(), + &mut buf, + ))?; + let bytes = if buf.len == 0 { + Vec::new() + } else { + std::slice::from_raw_parts(buf.data, buf.len).to_vec() + }; + hlw_buf_delete(&mut buf); + Ok(bytes) + } + } + + fn add(&self, left: u32, right: u32) -> u32 { + let bytes = self + .call( + ADD, + &[left.to_le_bytes().to_vec(), right.to_le_bytes().to_vec()], + ) + .unwrap(); + u32::from_le_bytes(bytes.try_into().unwrap()) + } +} + +impl Drop for Sandbox { + fn drop(&mut self) { + // SAFETY: the sandbox is live and not used afterwards. + unsafe { hlw_sandbox_delete(self.0) }; + } +} + +#[test] +#[ignore = "needs a guest runtime built for the component_sample example world"] +fn calls_exports_and_imports() { + let host = Arc::new(Host::default()); + let sandbox = Sandbox::new(&host, false); + + assert_eq!(sandbox.add(1, 2), 3); + let mut snapshot = std::ptr::null_mut(); + // SAFETY: the sandbox is live and `snapshot` is writable. + ok(unsafe { hlw_sandbox_snapshot(sandbox.0, &mut snapshot) }); + + // The debug build of the sample traps on overflow. A trapped instance + // can only be used again after a restore. + let trap = sandbox + .call( + ADD, + &[u32::MAX.to_le_bytes().to_vec(), 1u32.to_le_bytes().to_vec()], + ) + .unwrap_err(); + assert!(trap.message.contains("add_overflow"), "{}", trap.message); + assert!(sandbox.call(ADD, &[vec![0; 4], vec![0; 4]]).is_err()); + // SAFETY: the sandbox and snapshot are live. + unsafe { + ok(hlw_sandbox_restore(sandbox.0, snapshot)); + hlw_snapshot_delete(snapshot); + } + assert_eq!(sandbox.add(3, 4), 7); + + let reply = sandbox.call(CALL_HOST, &[encode_string("Hello")]).unwrap(); + assert_eq!(decode_string(&reply), "Hello from component and the host!"); + + let reply = sandbox + .call(DO_SOMETHING, &[42u32.to_le_bytes().to_vec()]) + .unwrap(); + assert!(reply.is_empty()); + assert_eq!(*host.printed.lock().unwrap(), ["42"]); + + let missing = sandbox + .call("component-sample:example/adder#missing", &[]) + .unwrap_err(); + assert!(missing.message.contains("missing"), "{}", missing.message); + + drop(sandbox); + // Both registrations released their reference. + assert_eq!(Arc::strong_count(&host), 1); +} + +#[test] +#[ignore = "needs a guest runtime built for the component_sample example world"] +fn loads_from_memory() { + let sandbox = Sandbox::new(&Arc::new(Host::default()), true); + assert_eq!(sandbox.add(20, 22), 42); +} + +#[test] +#[ignore = "needs a guest runtime built for the component_sample example world"] +fn host_errors_fail_the_call() { + let host = Arc::new(Host { + fail: true, + ..Host::default() + }); + let sandbox = Sandbox::new(&host, false); + let mut snapshot = std::ptr::null_mut(); + // SAFETY: the sandbox is live and `snapshot` is writable. + ok(unsafe { hlw_sandbox_snapshot(sandbox.0, &mut snapshot) }); + + let error = sandbox + .call(CALL_HOST, &[encode_string("Hello")]) + .unwrap_err(); + assert!(error.message.contains("host failure"), "{}", error.message); + + // SAFETY: the sandbox and snapshot are live. + unsafe { + ok(hlw_sandbox_restore(sandbox.0, snapshot)); + hlw_snapshot_delete(snapshot); + } + assert_eq!(sandbox.add(2, 3), 5); +} + +#[test] +#[ignore = "needs a guest runtime built for the component_sample example world"] +fn interrupt_handle_is_idle_between_calls() { + let sandbox = Sandbox::new(&Arc::new(Host::default()), false); + let mut handle = std::ptr::null_mut(); + // SAFETY: the sandbox is live and `handle` is writable. + unsafe { + ok(hlw_sandbox_interrupt_handle(sandbox.0, &mut handle)); + assert!(!hlw_interrupt_handle_kill(handle)); + } + assert_eq!(sandbox.add(1, 1), 2); + // SAFETY: the handle is live and not used afterwards. + unsafe { hlw_interrupt_handle_delete(handle) }; +} diff --git a/src/hyperlight_wasm_macro/src/wasmguest.rs b/src/hyperlight_wasm_macro/src/wasmguest.rs index a086b554..7408b6c8 100644 --- a/src/hyperlight_wasm_macro/src/wasmguest.rs +++ b/src/hyperlight_wasm_macro/src/wasmguest.rs @@ -260,7 +260,7 @@ fn emit_import_instance<'b>( depth: u32, it: &Instance<'b>, ) -> TokenStream { - let mut s = s.with_cursor(wn.namespace_idents()); + let mut s = s.with_instance(&wn); s.cur_helper_mod = Some(kebab_to_namespace(wn.name)); s.cur_trait = Some(kebab_to_type(wn.name)); let imports = it @@ -283,7 +283,7 @@ fn emit_export_instance<'b>( path: Vec, it: &Instance<'b>, ) -> TokenStream { - let mut s = s.with_cursor(wn.namespace_idents()); + let mut s = s.with_instance(&wn); s.cur_helper_mod = Some(kebab_to_namespace(wn.name)); s.cur_trait = Some(kebab_to_type(wn.name)); let exports = it diff --git a/src/hyperlight_wasm_runtime/src/platform.rs b/src/hyperlight_wasm_runtime/src/platform.rs index 74f3ea90..1828a6ca 100644 --- a/src/hyperlight_wasm_runtime/src/platform.rs +++ b/src/hyperlight_wasm_runtime/src/platform.rs @@ -19,9 +19,11 @@ use core::ptr::NonNull; use core::sync::atomic::{AtomicPtr, AtomicU64, Ordering}; use hyperlight_common::vmem; +// The page fault handler resumes after mapping a page, which the +// architecture-independent hook does not allow. #[allow(deprecated)] use hyperlight_guest_bin::exception::arch; -use hyperlight_guest_bin::paging; +use hyperlight_guest_bin::{exception, paging}; // Extremely stupid virtual address allocator // 0x1_0000_0000 is where the module is @@ -128,47 +130,22 @@ pub extern "C" fn wasmtime_page_size() -> usize { type wasmtime_trap_handler_t = extern "C" fn(ip: usize, fp: usize, has_faulting_addr: bool, faulting_addr: usize); static WASMTIME_REQUESTED_TRAP_HANDLER: AtomicU64 = AtomicU64::new(0); -fn wasmtime_trap_handler( - exception_number: u64, - info: *mut arch::ExceptionInfo, - ctx: *mut arch::Context, - _page_fault_address: u64, -) -> bool { - let requested_handler = WASMTIME_REQUESTED_TRAP_HANDLER.load(Ordering::Relaxed); - if requested_handler != 0 { - #[allow(clippy::collapsible_if)] // We will add more cases - if exception_number == 6 { - // #UD - // we assume that handle_trap always longjmp's away, so don't bother - // setting up a terribly proper stack frame - unsafe { - let orig_rip = (&raw mut (*info).rip).read_volatile(); - (&raw mut (*info).rip).write_volatile(requested_handler); - // TODO: This only works on amd64 sysv - (&raw mut (*ctx).gprs[9]).write_volatile(orig_rip); - let orig_rbp = (&raw mut (*ctx).gprs[8]).read_volatile(); - (&raw mut (*ctx).gprs[10]).write_volatile(orig_rbp); - (&raw mut (*ctx).gprs[11]).write_volatile(0); - (&raw mut (*ctx).gprs[12]).write_volatile(0); - } - return true; - } - // TODO: Add handlers for any other traps that wasmtime needs - } - false + +/// Forwards #UD, which wasmtime raises for wasm traps, to wasmtime's handler. +extern "C" fn undefined_instruction_handler(ip: u64, fp: u64) -> ! { + let handler = WASMTIME_REQUESTED_TRAP_HANDLER.load(Ordering::Relaxed); + // SAFETY: `wasmtime_init_traps` stores a `wasmtime_trap_handler_t` + // before registering this function. + let handler: wasmtime_trap_handler_t = unsafe { core::mem::transmute(handler as usize) }; + // Wasmtime longjmps away when the trap came from wasm code. + handler(ip as usize, fp as usize, false, 0); + panic!("undefined instruction at {ip:#x}"); } #[no_mangle] pub extern "C" fn wasmtime_init_traps(handler: wasmtime_trap_handler_t) -> i32 { WASMTIME_REQUESTED_TRAP_HANDLER.store(handler as usize as u64, Ordering::Relaxed); - // On amd64, vector 6 is #UD - // See AMD64 Architecture Programmer's Manual, Volume 2 - // ยง8.2 Vectors, p. 245 - // Table 8-1: Interrupt Vector Source and Cause - arch::HANDLERS[6].store( - wasmtime_trap_handler as *const () as usize as u64, - Ordering::Release, - ); + exception::register_undefined_instruction_handler(undefined_instruction_handler); // TODO: Add handlers for any other traps that wasmtime needs, // probably including at least some floating-point // exceptions