From f92d2a113f6ad117be0c72feb6f5cc9d565f18ba Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:16:41 -0700 Subject: [PATCH 1/4] Use hyperlight's component wire format and report wasm traps Build against the hyperlight branch with the component wire format, which names component functions # and encodes numbers little-endian. Hosts and guests built with earlier versions cannot call each other. Wasm traps go through the undefined instruction hook, which turns a trap into a guest call error instead of a general protection fault. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- CHANGELOG.md | 8 ++ Cargo.lock | 90 ++++++++++++--------- Cargo.toml | 13 +++ src/hyperlight_wasm_macro/src/wasmguest.rs | 4 +- src/hyperlight_wasm_runtime/src/platform.rs | 51 ++++-------- 5 files changed, 89 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15d3fe67..41cd9a3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Prerelease] - Unreleased +### 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..4e6c069c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -187,21 +187,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", ] @@ -402,7 +401,7 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex 2.0.1", + "shlex", ] [[package]] @@ -1858,8 +1857,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#616d7889bc275bf6ae7c3d9be068cc0bd62873d0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -1878,8 +1876,7 @@ dependencies = [ [[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#616d7889bc275bf6ae7c3d9be068cc0bd62873d0" dependencies = [ "env_logger", "hyperlight-component-util", @@ -1893,8 +1890,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#616d7889bc275bf6ae7c3d9be068cc0bd62873d0" dependencies = [ "itertools 0.15.0", "prettyplease 0.3.0", @@ -1911,8 +1907,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#616d7889bc275bf6ae7c3d9be068cc0bd62873d0" dependencies = [ "anyhow", "flatbuffers", @@ -1926,8 +1921,7 @@ 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#616d7889bc275bf6ae7c3d9be068cc0bd62873d0" dependencies = [ "buddy_system_allocator", "flatbuffers", @@ -1945,8 +1939,7 @@ dependencies = [ [[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#616d7889bc275bf6ae7c3d9be068cc0bd62873d0" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -1957,8 +1950,7 @@ 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#616d7889bc275bf6ae7c3d9be068cc0bd62873d0" dependencies = [ "hyperlight-common", "spin 0.12.3", @@ -1969,8 +1961,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#616d7889bc275bf6ae7c3d9be068cc0bd62873d0" dependencies = [ "anyhow", "bindgen", @@ -1999,7 +1990,7 @@ dependencies = [ "mshv-bindings", "mshv-ioctls", "oci-spec", - "opentelemetry", + "opentelemetry 0.33.1", "page_size", "parking_lot", "rand 0.10.3", @@ -2012,7 +2003,7 @@ dependencies = [ "thiserror", "tracing", "tracing-core", - "tracing-opentelemetry", + "tracing-opentelemetry 0.34.0", "uuid", "vmm-sys-util", "windows", @@ -2024,8 +2015,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#616d7889bc275bf6ae7c3d9be068cc0bd62873d0" dependencies = [ "anyhow", "bindgen", @@ -2061,7 +2051,7 @@ dependencies = [ "metrics-exporter-prometheus", "metrics-util", "once_cell", - "opentelemetry", + "opentelemetry 0.32.0", "opentelemetry-otlp", "opentelemetry-semantic-conventions", "opentelemetry_sdk", @@ -2074,7 +2064,7 @@ dependencies = [ "toml", "tracing", "tracing-forest", - "tracing-opentelemetry", + "tracing-opentelemetry 0.33.0", "tracing-subscriber", "uuid", "windows", @@ -2872,6 +2862,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 +2885,7 @@ dependencies = [ "async-trait", "bytes", "http", - "opentelemetry", + "opentelemetry 0.32.0", "reqwest", ] @@ -2892,7 +2896,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 +2914,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 +2936,7 @@ dependencies = [ "futures-channel", "futures-executor", "futures-util", - "opentelemetry", + "opentelemetry 0.32.0", "percent-encoding", "portable-atomic", "rand 0.9.5", @@ -3828,12 +3832,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" @@ -4345,7 +4343,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..43b6d422 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,16 @@ 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" } 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 From 92d6b1143f288bb7391c95a822a997ce16daa8e9 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:17:06 -0700 Subject: [PATCH 2/4] Load the guest runtime from a file and set the interrupt signal SandboxBuilder::with_runtime_file loads the guest runtime from a file instead of the one built into the crate. A host built once can then run components of any world whose runtime was built separately. SandboxBuilder::with_interrupt_vcpu_sigrtmin_offset changes the signal that interrupts a running guest on Linux. Hosts such as .NET use SIGRTMIN themselves. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- CHANGELOG.md | 4 +++ .../src/sandbox/sandbox_builder.rs | 31 ++++++++++++++++++- .../src/sandbox/wasm_sandbox.rs | 30 ++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41cd9a3e..880dbc26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Prerelease] - Unreleased +### Added + +- 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. 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(); From 59a50f619acdfe702f70a9e09b5e0d35ebc9d9a0 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:17:24 -0700 Subject: [PATCH 3/4] Add a C API for components Add hyperlight-wasm-capi, a C library declared in include/hyperlight_wasm.h. It registers host functions, loads components, calls guest functions, and snapshots, restores and interrupts sandboxes. Arguments and results are byte buffers in hyperlight's component wire format, so the library needs no knowledge of a component's WIT. Host functions take up to 32 parameters. A panic in a guest call becomes an error and leaves the sandbox unusable. just examples-components runs its tests against the component_sample example world. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- .gitignore | 1 + CHANGELOG.md | 1 + Cargo.lock | 8 + Cargo.toml | 2 +- Justfile | 1 + src/hyperlight_wasm_capi/Cargo.toml | 23 + .../include/hyperlight_wasm.h | 242 +++++ src/hyperlight_wasm_capi/src/lib.rs | 969 ++++++++++++++++++ src/hyperlight_wasm_capi/tests/component.rs | 306 ++++++ 9 files changed, 1552 insertions(+), 1 deletion(-) create mode 100644 src/hyperlight_wasm_capi/Cargo.toml create mode 100644 src/hyperlight_wasm_capi/include/hyperlight_wasm.h create mode 100644 src/hyperlight_wasm_capi/src/lib.rs create mode 100644 src/hyperlight_wasm_capi/tests/component.rs 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 880dbc26..b7b2e1cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### 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 `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 diff --git a/Cargo.lock b/Cargo.lock index 4e6c069c..6e57629d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2082,6 +2082,14 @@ dependencies = [ "wasmtime 47.0.4", ] +[[package]] +name = "hyperlight-wasm-capi" +version = "0.15.0" +dependencies = [ + "examples_common", + "hyperlight-wasm", +] + [[package]] name = "hyperlight-wasm-macro" version = "0.15.0" diff --git a/Cargo.toml b/Cargo.toml index 43b6d422..00b82e72 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/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" diff --git a/Justfile b/Justfile index 360bf132..e83f7a8b 100644 --- a/Justfile +++ b/Justfile @@ -124,6 +124,7 @@ 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 # 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/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) }; +} From fbcf9c81b100ac70e8acc3760b9902712f91b284 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:18:17 -0700 Subject: [PATCH 4/4] Add .NET packages for components Hyperlight.Wasm runs components from .NET through the C API. WasmSandbox registers host functions, loads components and calls them, with snapshots, interrupts and CancellationToken support. Host exceptions become the InnerException of the failed call. The interrupt signal defaults to SIGRTMIN + 1, because the .NET runtime suspends threads for garbage collection with SIGRTMIN. With offset 0 a collection can hang. Hyperlight.Wasm.Build generates C# bindings from HyperlightWit items at build time. Its MSBuild task loads hyperlight-wasm-bindgen, a native library around hyperlight's C# generator, so builds need no Rust. just dotnet-test runs the tests against the component_sample example world. just dotnet-pack packs both packages for the build machine's runtime identifier. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- .github/dependabot.yml | 8 + CHANGELOG.md | 1 + Cargo.lock | 15 + Cargo.toml | 4 +- Justfile | 15 + README.md | 7 + dotnet/Directory.Build.props | 23 + dotnet/Directory.Build.targets | 8 + dotnet/README.md | 72 +++ .../GenerateHyperlightBindings.cs | 128 +++++ .../Hyperlight.Wasm.Build.csproj | 36 ++ .../build/Hyperlight.Wasm.Build.props | 18 + .../build/Hyperlight.Wasm.Build.targets | 35 ++ .../src/Hyperlight.Wasm/Component/Runtime.cs | 475 ++++++++++++++++++ .../Hyperlight.Wasm/Hyperlight.Wasm.csproj | 32 ++ .../Hyperlight.Wasm/HyperlightException.cs | 39 ++ dotnet/src/Hyperlight.Wasm/Native.cs | 185 +++++++ dotnet/src/Hyperlight.Wasm/SandboxOptions.cs | 50 ++ dotnet/src/Hyperlight.Wasm/WasmSandbox.cs | 356 +++++++++++++ .../Hyperlight.Wasm.Tests/AssemblyInfo.cs | 4 + .../Hyperlight.Wasm.Tests/ComponentTests.cs | 259 ++++++++++ .../Hyperlight.Wasm.Tests.csproj | 40 ++ src/hyperlight_wasm_bindgen/Cargo.toml | 19 + src/hyperlight_wasm_bindgen/src/lib.rs | 165 ++++++ 24 files changed, 1993 insertions(+), 1 deletion(-) create mode 100644 dotnet/Directory.Build.props create mode 100644 dotnet/Directory.Build.targets create mode 100644 dotnet/README.md create mode 100644 dotnet/src/Hyperlight.Wasm.Build/GenerateHyperlightBindings.cs create mode 100644 dotnet/src/Hyperlight.Wasm.Build/Hyperlight.Wasm.Build.csproj create mode 100644 dotnet/src/Hyperlight.Wasm.Build/build/Hyperlight.Wasm.Build.props create mode 100644 dotnet/src/Hyperlight.Wasm.Build/build/Hyperlight.Wasm.Build.targets create mode 100644 dotnet/src/Hyperlight.Wasm/Component/Runtime.cs create mode 100644 dotnet/src/Hyperlight.Wasm/Hyperlight.Wasm.csproj create mode 100644 dotnet/src/Hyperlight.Wasm/HyperlightException.cs create mode 100644 dotnet/src/Hyperlight.Wasm/Native.cs create mode 100644 dotnet/src/Hyperlight.Wasm/SandboxOptions.cs create mode 100644 dotnet/src/Hyperlight.Wasm/WasmSandbox.cs create mode 100644 dotnet/tests/Hyperlight.Wasm.Tests/AssemblyInfo.cs create mode 100644 dotnet/tests/Hyperlight.Wasm.Tests/ComponentTests.cs create mode 100644 dotnet/tests/Hyperlight.Wasm.Tests/Hyperlight.Wasm.Tests.csproj create mode 100644 src/hyperlight_wasm_bindgen/Cargo.toml create mode 100644 src/hyperlight_wasm_bindgen/src/lib.rs 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/CHANGELOG.md b/CHANGELOG.md index b7b2e1cc..92fb8d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### 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 diff --git a/Cargo.lock b/Cargo.lock index 6e57629d..dffbcc39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1873,6 +1873,14 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "hyperlight-component-csharp" +version = "0.18.0" +source = "git+https://github.com/hyperlight-dev/hyperlight?branch=component-wire-format#616d7889bc275bf6ae7c3d9be068cc0bd62873d0" +dependencies = [ + "hyperlight-component-util", +] + [[package]] name = "hyperlight-component-macro" version = "0.18.0" @@ -2082,6 +2090,13 @@ 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" diff --git a/Cargo.toml b/Cargo.toml index 00b82e72..2c987333 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [ "src/hyperlight_wasm", "src/hyperlight_wasm_capi", "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,6 +15,7 @@ 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" @@ -34,3 +35,4 @@ hyperlight-guest-tracing = { git = "https://github.com/hyperlight-dev/hyperlight 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 e83f7a8b..05212929 100644 --- a/Justfile +++ b/Justfile @@ -126,6 +126,21 @@ examples-components target=default-target features="": (build-rust-component-exa {{ 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) {{ wit-world }} cargo run {{ if features =="" {'-F pulley'} else {"--no-default-features -F kvm,pulley -F " + features } }} --profile={{ if target == "debug" {"dev"} else { target } }} --example component_example 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_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}" + ); + } +}