From f76f7832501c4a93a5a315e1f932200f0d4ef97f Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Thu, 8 Oct 2026 22:03:59 +0100 Subject: [PATCH 1/5] Add persistent sandbox snapshot restoration Add lifecycle-aware persistent snapshots for runtime-ready and loaded JavaScript sandboxes across the Rust and Node APIs. Restore process-local host resources through SandboxBuilder, keep captured requirements separate from available host callbacks, and prevent accidental callback sharing. BREAKING CHANGE: hyperlight_js::Snapshot now names the lifecycle-aware snapshot wrapper, LoadedJSSandbox snapshot and restore use that wrapper, HostModule is no longer cloneable, and Node ESM no longer exports internal Wrapper aliases. Signed-off-by: Simon Davies --- Cargo.lock | 29 +- Cargo.toml | 8 +- Justfile | 7 + .../fixtures/extended_runtime/Cargo.lock | 18 +- src/hyperlight-js/Cargo.toml | 7 +- src/hyperlight-js/benches/benchmarks.rs | 52 +- .../examples/persistent_snapshot/main.rs | 85 +++ src/hyperlight-js/src/lib.rs | 8 +- src/hyperlight-js/src/resolver.rs | 6 +- src/hyperlight-js/src/sandbox/host_fn.rs | 310 ++++++++- src/hyperlight-js/src/sandbox/js_sandbox.rs | 96 ++- .../src/sandbox/loaded_js_sandbox.rs | 73 ++- src/hyperlight-js/src/sandbox/mod.rs | 6 + .../src/sandbox/module_loader.rs | 84 +++ .../src/sandbox/proto_js_sandbox.rs | 114 +--- .../src/sandbox/sandbox_builder.rs | 99 ++- .../src/sandbox/sandbox_restorer.rs | 147 +++++ src/hyperlight-js/src/sandbox/snapshot.rs | 534 +++++++++++++++ src/hyperlight-js/src/script.rs | 11 + src/hyperlight-js/tests/host_functions.rs | 297 ++++++++- src/hyperlight-js/tests/module_loader.rs | 193 +++++- src/hyperlight-js/tests/native_modules.rs | 64 +- .../tests/persistent_snapshots.rs | 293 +++++++++ src/js-host-api/examples/host-functions.js | 44 +- .../examples/persistent-snapshot.js | 74 +++ src/js-host-api/lib.js | 104 ++- src/js-host-api/lib.mjs | 12 +- src/js-host-api/package-lock.json | 6 +- src/js-host-api/package.json | 18 +- .../scripts/normalize-generated-types.mjs | 40 ++ src/js-host-api/src/lib.rs | 615 +++++++++++++++--- .../tests/persistent-snapshots.test.js | 253 +++++++ .../{vitest.config.js => vitest.config.mjs} | 2 + 33 files changed, 3398 insertions(+), 311 deletions(-) create mode 100644 src/hyperlight-js/examples/persistent_snapshot/main.rs create mode 100644 src/hyperlight-js/src/sandbox/module_loader.rs create mode 100644 src/hyperlight-js/src/sandbox/sandbox_restorer.rs create mode 100644 src/hyperlight-js/src/sandbox/snapshot.rs create mode 100644 src/hyperlight-js/tests/persistent_snapshots.rs create mode 100644 src/js-host-api/examples/persistent-snapshot.js create mode 100644 src/js-host-api/scripts/normalize-generated-types.mjs create mode 100644 src/js-host-api/tests/persistent-snapshots.test.js rename src/js-host-api/{vitest.config.js => vitest.config.mjs} (68%) diff --git a/Cargo.lock b/Cargo.lock index bc9f9a08..f23fc5f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1614,8 +1614,7 @@ dependencies = [ [[package]] name = "hyperlight-common" version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad5576d6ef947dc2822c2f5038ddff8325c158ddef4cbabf9d0b125294cdb0f" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -1634,8 +1633,7 @@ dependencies = [ [[package]] name = "hyperlight-guest" version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "816f673f3f8ef6d5703796bf92927b85177ad1baac6f391551a88451a9e46a7e" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "anyhow", "flatbuffers", @@ -1648,8 +1646,7 @@ dependencies = [ [[package]] name = "hyperlight-guest-bin" version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c2d0ea739be89822992706da9dc6812ce359b654b328efd6141ba1549751907" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "buddy_system_allocator", "flatbuffers", @@ -1667,8 +1664,7 @@ dependencies = [ [[package]] name = "hyperlight-guest-macro" version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875d03a9e4d302f29d8feb57faff937f80c6f69ec9d31b32f41c41721144e25d" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -1679,8 +1675,7 @@ dependencies = [ [[package]] name = "hyperlight-guest-tracing" version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f43a3d5d3fa245ce0fccee8a48ad7d94ec479e0898fc8f91fd440a1d65830c6" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "hyperlight-common", "spin 0.12.3", @@ -1691,8 +1686,7 @@ dependencies = [ [[package]] name = "hyperlight-host" version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85dcf5ae7a578e1b09bdeb76cc3b3ca8e6e57d6395d2f57204aec45c1bbbde9" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "anyhow", "bindgen", @@ -1821,8 +1815,7 @@ dependencies = [ [[package]] name = "hyperlight-libc" version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "108dd0e7335821a2ef0dfa375c08236c3302a117045822e2aa68c64147f8e895" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "anyhow", "bindgen", @@ -2329,9 +2322,9 @@ dependencies = [ [[package]] name = "mshv-bindings" -version = "0.6.9" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83303108160c2b7a7bdd25000ee679384e19471386d23e501ed832574c9229ef" +checksum = "9b4b2414499597c6e31b1cd25239f19e8e8c55023b3bf8fcfa44d927cff6a123" dependencies = [ "libc", "num_enum", @@ -2341,9 +2334,9 @@ dependencies = [ [[package]] name = "mshv-ioctls" -version = "0.6.9" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1db4449ac7012237b133da366f5b32ce4af1f8caf770486e5a9d54f7f6b73c4c" +checksum = "c30d1b5b76a709e30b46248f52bbb44dde04a659d6d58255c7f06c88d528d210" dependencies = [ "libc", "mshv-bindings", diff --git a/Cargo.toml b/Cargo.toml index 345d4a98..239492a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,10 +12,10 @@ repository = "https://github.com/hyperlight-dev/hyperlight-js" readme = "README.md" [workspace.dependencies] -hyperlight-guest-bin = { version = "0.17.0", features = ["libc"] } -hyperlight-host = { version = "0.17.0", default-features = false } -hyperlight-common = { version = "0.17.0", default-features = false } -hyperlight-guest = { version = "0.17.0" } +hyperlight-guest-bin = { git = "https://github.com/hyperlight-dev/hyperlight", rev = "fe19bf0645b9969752cf215669980fa9075dc953", features = ["libc"] } +hyperlight-host = { git = "https://github.com/hyperlight-dev/hyperlight", rev = "fe19bf0645b9969752cf215669980fa9075dc953", default-features = false } +hyperlight-common = { git = "https://github.com/hyperlight-dev/hyperlight", rev = "fe19bf0645b9969752cf215669980fa9075dc953", default-features = false } +hyperlight-guest = { git = "https://github.com/hyperlight-dev/hyperlight", rev = "fe19bf0645b9969752cf215669980fa9075dc953" } hyperlight-js = { version = "0.4.0", path = "src/hyperlight-js" } hyperlight-js-runtime = { version = "0.4.0", path = "src/hyperlight-js-runtime" } diff --git a/Justfile b/Justfile index ab63346a..7abc764d 100644 --- a/Justfile +++ b/Justfile @@ -124,6 +124,7 @@ build-rust-trace target=default-target features="": build-js-host-api target=default-target features="": check-npm (build-rust target features) cd src/js-host-api && npm install cd src/js-host-api && npx napi build --platform {{ if target == "release" { "--release" } else { "" } }} {{ if features == "" { "" } else { "--features=" + features } }} + cd src/js-host-api && npm run normalize:types build-all: (build "debug") (build "release") @echo "✅ All builds complete!" @@ -135,6 +136,8 @@ run-examples target=default-target features="": (build target) cargo run --profile={{ if target == "debug" {"dev"} else { target } }} {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F function_call_metrics," + features } }} --example metrics cargo run --profile={{ if target == "debug" {"dev"} else { target } }} {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --example metrics cargo run --profile={{ if target == "debug" {"dev"} else { target } }} {{ if features =="" {'--features guest-call-stats,monitor-wall-clock,monitor-cpu-time'} else if features=="no-default-features" {"--no-default-features -F guest-call-stats,monitor-wall-clock,monitor-cpu-time" } else {"--no-default-features -F guest-call-stats,monitor-wall-clock,monitor-cpu-time," + features } }} --example execution_stats + cargo run --profile={{ if target == "debug" {"dev"} else { target } }} {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --example persistent_snapshot -- write target/persistent-rust-snapshot + cargo run --profile={{ if target == "debug" {"dev"} else { target } }} {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --example persistent_snapshot -- read target/persistent-rust-snapshot run-examples-tracing target=default-target features="": (build target) cargo run --profile={{ if target == "debug" {"dev"} else { target } }} {{ if features =="" {'--features function_call_metrics'} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --example tracing fmt @@ -170,6 +173,7 @@ test-monitors target=default-target: cd src/hyperlight-js && cargo test --features monitor-wall-clock,monitor-cpu-time --profile={{ if target == "debug" {"dev"} else { target } }} -- --include-ignored --skip test_metrics --skip custom_native_module --skip builtin_modules_work_with_custom --skip console_log_works_with_custom --skip custom_globals_and_host_clock test-js-host-api target=default-target features="": (build-js-host-api target features) + cargo test --manifest-path src/js-host-api/Cargo.toml --profile={{ if target == "debug" {"dev"} else { target } }} cd src/js-host-api && npm test # Test custom native modules: @@ -247,6 +251,9 @@ run-js-host-api-examples target=default-target features="": (build-js-host-api t @echo "" cd src/js-host-api && node examples/user-modules.js @echo "" + cd src/js-host-api && node examples/persistent-snapshot.js write ../../target/persistent-node-snapshot + cd src/js-host-api && node examples/persistent-snapshot.js read ../../target/persistent-node-snapshot + @echo "" @echo "✅ All examples completed successfully!" test-all target=default-target features="": (test target features) (test-monitors target) (test-js-host-api target features) diff --git a/src/hyperlight-js-runtime/tests/fixtures/extended_runtime/Cargo.lock b/src/hyperlight-js-runtime/tests/fixtures/extended_runtime/Cargo.lock index 67ff8504..30138b36 100644 --- a/src/hyperlight-js-runtime/tests/fixtures/extended_runtime/Cargo.lock +++ b/src/hyperlight-js-runtime/tests/fixtures/extended_runtime/Cargo.lock @@ -431,8 +431,7 @@ dependencies = [ [[package]] name = "hyperlight-common" version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad5576d6ef947dc2822c2f5038ddff8325c158ddef4cbabf9d0b125294cdb0f" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "anyhow", "bitflags", @@ -450,8 +449,7 @@ dependencies = [ [[package]] name = "hyperlight-guest" version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "816f673f3f8ef6d5703796bf92927b85177ad1baac6f391551a88451a9e46a7e" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "anyhow", "flatbuffers", @@ -463,8 +461,7 @@ dependencies = [ [[package]] name = "hyperlight-guest-bin" version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c2d0ea739be89822992706da9dc6812ce359b654b328efd6141ba1549751907" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "buddy_system_allocator", "flatbuffers", @@ -482,8 +479,7 @@ dependencies = [ [[package]] name = "hyperlight-guest-macro" version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875d03a9e4d302f29d8feb57faff937f80c6f69ec9d31b32f41c41721144e25d" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -494,8 +490,7 @@ dependencies = [ [[package]] name = "hyperlight-guest-tracing" version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f43a3d5d3fa245ce0fccee8a48ad7d94ec479e0898fc8f91fd440a1d65830c6" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "hyperlight-common", "spin 0.12.0", @@ -534,8 +529,7 @@ dependencies = [ [[package]] name = "hyperlight-libc" version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "108dd0e7335821a2ef0dfa375c08236c3302a117045822e2aa68c64147f8e895" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "anyhow", "bindgen", diff --git a/src/hyperlight-js/Cargo.toml b/src/hyperlight-js/Cargo.toml index c54d6365..b7aa43ad 100644 --- a/src/hyperlight-js/Cargo.toml +++ b/src/hyperlight-js/Cargo.toml @@ -22,7 +22,7 @@ hyperlight-js-runtime = { workspace = true } metrics = "0.24.6" oxc_resolver = "11.21.0" phf = { version = "0.14", features = ["macros"] } -serde = { version = "1.0" } +serde = { version = "1.0", features = ["derive", "rc"] } serde_json = { version = "1.0" } tracing = "0.1.44" @@ -135,6 +135,11 @@ name = "user_modules" path = "examples/user_modules/main.rs" test = false +[[example]] +name = "persistent_snapshot" +path = "examples/persistent_snapshot/main.rs" +test = false + [[bench]] name = "benchmarks" harness = false diff --git a/src/hyperlight-js/benches/benchmarks.rs b/src/hyperlight-js/benches/benchmarks.rs index 23c306f5..62dc0cd0 100644 --- a/src/hyperlight-js/benches/benchmarks.rs +++ b/src/hyperlight-js/benches/benchmarks.rs @@ -21,7 +21,7 @@ use std::time::{Duration, Instant}; use criterion::{criterion_group, criterion_main, Bencher, Criterion}; #[cfg(all(feature = "monitor-wall-clock", feature = "monitor-cpu-time"))] use hyperlight_js::{CpuTimeMonitor, WallClockMonitor}; -use hyperlight_js::{SandboxBuilder, Script}; +use hyperlight_js::{SandboxBuilder, Script, Snapshot}; fn js_load_handler_benchmark(c: &mut Criterion) { let mut group = c.benchmark_group("js_get_loaded_sandbox"); @@ -344,6 +344,54 @@ fn full_lifecycle_benchmark(c: &mut Criterion) { group.finish(); } +fn persistent_snapshot_benchmark(c: &mut Criterion) { + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox + .add_handler( + "counter", + Script::from_content( + r#" + let count = 0; + function handler(event) { + event.count = ++count; + return event; + } + "#, + ), + ) + .unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + loaded + .handle_event("counter", "{}".to_owned(), None) + .unwrap(); + let snapshot = loaded.snapshot().unwrap(); + let directory = tempfile::tempdir().unwrap(); + snapshot.save(directory.path(), "benchmark").unwrap(); + + let mut group = c.benchmark_group("persistent_snapshots"); + group.bench_function("save_loaded_snapshot", |b| { + b.iter(|| snapshot.save(directory.path(), "benchmark").unwrap()) + }); + group.bench_function("checked_load", |b| { + b.iter(|| Snapshot::load(directory.path(), "benchmark").unwrap()) + }); + group.bench_function("restore_loaded_sandbox", |b| { + b.iter(|| { + let snapshot = Snapshot::load(directory.path(), "benchmark").unwrap(); + SandboxBuilder::new() + .build_from_snapshot(snapshot) + .unwrap() + .restore::() + .unwrap() + }) + }); + group.finish(); +} + // ============================================================================= // Monitor overhead benchmark // ============================================================================= @@ -449,7 +497,7 @@ fn monitor_overhead_benchmark(c: &mut Criterion) { criterion_group! { name = benches; config = Criterion::default().measurement_time(Duration::from_secs(20)); - targets = js_load_handler_benchmark, handle_events_benchmark, full_lifecycle_benchmark + targets = js_load_handler_benchmark, handle_events_benchmark, full_lifecycle_benchmark, persistent_snapshot_benchmark } #[cfg(all(feature = "monitor-wall-clock", feature = "monitor-cpu-time"))] diff --git a/src/hyperlight-js/examples/persistent_snapshot/main.rs b/src/hyperlight-js/examples/persistent_snapshot/main.rs new file mode 100644 index 00000000..b3210d78 --- /dev/null +++ b/src/hyperlight-js/examples/persistent_snapshot/main.rs @@ -0,0 +1,85 @@ +/* +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. +*/ + +use std::path::PathBuf; + +use hyperlight_js::{ + HostFunctionFilter, HostFunctionModule, LoadedJSSandbox, SandboxBuilder, Script, Snapshot, +}; + +fn host_function_modules() -> Vec { + let mut counter = HostFunctionModule::new("host:counter"); + counter.register("step", |value: i32| value + 1); + vec![counter] +} + +fn main() -> hyperlight_js::Result<()> { + let mut arguments = std::env::args().skip(1); + let command = arguments.next().unwrap_or_else(|| "write".to_owned()); + let path = arguments + .next() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("target/persistent-js-snapshot")); + + match command.as_str() { + "write" => write_snapshot(path), + "read" => read_snapshot(path), + other => Err(hyperlight_js::new_error!( + "Unknown command '{other}'; expected 'write' or 'read'" + )), + } +} + +fn write_snapshot(path: PathBuf) -> hyperlight_js::Result<()> { + let mut sandbox = SandboxBuilder::new() + .with_host_function_modules(host_function_modules) + .build()? + .load_runtime()?; + sandbox.add_handler( + "counter", + Script::from_content( + r#" + import * as counter from "host:counter"; + let count = 0; + function handler(event) { + count = counter.step(count); + event.count = count; + return event; + } + "#, + ), + )?; + let mut loaded = sandbox.get_loaded_sandbox()?; + let result = loaded.handle_event("counter", "{}".to_owned(), None)?; + println!("Before snapshot: {result}"); + + let snapshot = loaded.snapshot()?; + let digest = snapshot.save(path, "application-ready")?; + println!("Saved application-ready as {digest}"); + Ok(()) +} + +fn read_snapshot(path: PathBuf) -> hyperlight_js::Result<()> { + let snapshot = Snapshot::load(path, "application-ready")?; + let restorer = SandboxBuilder::new() + .with_host_function_modules(host_function_modules) + .with_host_function_filter(HostFunctionFilter::snapshot_requirements()) + .build_from_snapshot(snapshot)?; + let mut loaded = restorer.restore::()?; + let result = loaded.handle_event("counter", "{}".to_owned(), None)?; + println!("After restore: {result}"); + Ok(()) +} diff --git a/src/hyperlight-js/src/lib.rs b/src/hyperlight-js/src/lib.rs index 04d9c957..9898cf2c 100644 --- a/src/hyperlight-js/src/lib.rs +++ b/src/hyperlight-js/src/lib.rs @@ -30,6 +30,8 @@ use hyperlight_host::func::HostFunction; // Re-export FnReturn for the NAPI bridge (used in register_js signature). #[doc(hidden)] pub use sandbox::host_fn::FnReturn; +/// Selects reusable host functions when building a fresh or restored sandbox. +pub use sandbox::host_fn::{HostFunctionFilter, HostFunctionModule}; /// Validate a module identifier (name or namespace). pub use sandbox::js_sandbox::validate_module_identifier; /// Validate that a namespace is not reserved. @@ -47,6 +49,10 @@ pub use sandbox::loaded_js_sandbox::LoadedJSSandbox; pub use sandbox::proto_js_sandbox::ProtoJSSandbox; /// A builder for creating a new `JSSandbox` pub use sandbox::sandbox_builder::SandboxBuilder; +/// Restores a snapshot using host resources configured on [`SandboxBuilder`]. +pub use sandbox::sandbox_restorer::{RestoreTarget, SandboxRestorer}; +/// A persistent snapshot of a Hyperlight JavaScript sandbox. +pub use sandbox::snapshot::{RequirementStatus, Snapshot, SnapshotKind, SnapshotRequirements}; /// Types for working with JS script. pub use script::Script; /// The function to pass to a new `JSSandbox` to tell it how to handle @@ -69,8 +75,6 @@ pub type ParameterValue = hyperlight_host::func::ParameterValue; pub type ReturnValue = hyperlight_host::func::ReturnValue; /// The type of the return value from a guest function call. pub type ReturnType = hyperlight_host::func::ReturnType; -/// A snapshot of sandbox state that can be used to restore it later. -pub use hyperlight_host::sandbox::snapshot::Snapshot; /// Configuration for sandbox resource limits and behavior. pub use hyperlight_host::sandbox::{SandboxConfiguration, SandboxStatus}; /// Module resolution and loading functionality. diff --git a/src/hyperlight-js/src/resolver.rs b/src/hyperlight-js/src/resolver.rs index 094ad985..75bf5580 100644 --- a/src/hyperlight-js/src/resolver.rs +++ b/src/hyperlight-js/src/resolver.rs @@ -172,9 +172,9 @@ impl FileSystem for FileSystemEmbedded { /// "strings.js" => "../tests/fixtures/strings.js", /// }; /// -/// let proto_js_sandbox = SandboxBuilder::new().build().unwrap(); -/// let sandbox = proto_js_sandbox -/// .set_module_loader(fs) +/// let sandbox = SandboxBuilder::new() +/// .with_module_loader(fs) +/// .build() /// .unwrap() /// .load_runtime() /// .unwrap(); diff --git a/src/hyperlight-js/src/sandbox/host_fn.rs b/src/hyperlight-js/src/sandbox/host_fn.rs index a283471e..523264f8 100644 --- a/src/hyperlight-js/src/sandbox/host_fn.rs +++ b/src/hyperlight-js/src/sandbox/host_fn.rs @@ -20,6 +20,8 @@ use serde::ser::SerializeSeq; use serde::Serialize; use serde_json::Value as JsonValue; +use super::snapshot::HostFunctionManifest; + // Unlike hyperlight-host's Function, this Function trait uses `serde`'s Serialize and DeserializeOwned traits for input and output types. /// A trait representing a host function that can be called from the guest JavaScript code. @@ -49,25 +51,100 @@ where } } -type JsonFn = std::sync::Arc crate::Result + Send + Sync>; +type JsonFn = Box crate::Result + Send + Sync>; /// Re-export the unified return type from the common crate. pub use hyperlight_js_common::FnReturn; +/// Expose a logical host-function manifest to the guest module loader. +pub(crate) fn register_host_function_manifest( + sandbox: &mut hyperlight_host::MultiUseSandbox, + manifest: &HostFunctionManifest, +) -> crate::Result<()> { + let manifest_json = serde_json::to_string(manifest)?; + sandbox.call("RegisterHostModules", manifest_json) +} + +/// Return the registered host functions grouped by module. +pub(crate) fn host_function_manifest( + modules: &HashMap, +) -> HostFunctionManifest { + modules + .iter() + .map(|(module, functions)| { + let mut functions = functions + .function_names() + .map(str::to_owned) + .collect::>(); + functions.sort(); + (module.clone(), functions) + }) + .collect() +} + +/// Build the single Hyperlight callback that dispatches logical host functions. +pub(crate) fn host_function_dispatch( + modules: HashMap, +) -> impl Fn(String, String, String, Vec) -> crate::Result> + Send + Sync + 'static { + move |module_name, function_name, args_json, binaries| { + let module = modules + .get(&module_name) + .ok_or_else(|| crate::new_error!("Host module '{}' not found", module_name))?; + module + .call(&function_name, args_json, Some(binaries)) + .map_err(|error| { + crate::new_error!( + "Error calling host function '{}' in module '{}': {}", + function_name, + module_name, + error + ) + }) + } +} + +/// Selects host functions to install when building a sandbox. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum HostFunctionFilter { + /// Make every supplied host function available to the sandbox. + /// + /// Restoring a loaded snapshot preserves its captured requirements. Extra + /// supplied functions become visible only after unloading back to a + /// [`crate::JSSandbox`] and loading newly staged handlers. + #[default] + All, + /// Install only functions declared by the snapshot requirements. + /// + /// Extra supplied functions are discarded and do not become available after + /// unload. This filter is valid only with + /// [`crate::SandboxBuilder::build_from_snapshot`]. + SnapshotRequirements, +} + +impl HostFunctionFilter { + /// Make every supplied host function available to the sandbox lifecycle. + pub const fn all() -> Self { + Self::All + } + + /// Make only functions required by the restored snapshot available. + pub const fn snapshot_requirements() -> Self { + Self::SnapshotRequirements + } +} + /// The closure type for JS bridge host functions. /// /// Receives the parsed JSON arguments (with `{"__bin__": N}` placeholders /// still in place) and the decoded individual binary blobs. This avoids a /// redundant stringify→parse round-trip that would occur if we passed a /// pre-processed JSON string. -type BinaryFn = - std::sync::Arc>) -> crate::Result + Send + Sync>; +type BinaryFn = Box>) -> crate::Result + Send + Sync>; /// A registered host function — either typed (serde) or JS bridge. /// /// This enum allows a single `HashMap` to store both variants, eliminating /// the need for parallel maps and cross-removal bookkeeping. -#[derive(Clone)] enum HostFn { /// Typed: receives a JSON args string, deserializes via serde, /// returns a JSON result string. Does not support binary args. @@ -80,7 +157,7 @@ enum HostFn { fn type_erased( func: impl Function + Send + Sync + 'static, ) -> JsonFn { - std::sync::Arc::new(move |args: String| { + Box::new(move |args: String| { let args: Args = serde_json::from_str(&args)?; let output: Output = func.call(args); Ok(serde_json::to_string(&output)?) @@ -97,7 +174,7 @@ pub(crate) fn decode_binaries(data: &[u8]) -> crate::Result>> { } /// A module containing host functions that can be called from the guest JavaScript code. -#[derive(Default, Clone)] +#[derive(Default)] pub struct HostModule { functions: HashMap, } @@ -115,6 +192,15 @@ impl Serialize for HostModule { } impl HostModule { + pub(crate) fn function_names(&self) -> impl Iterator { + self.functions.keys().map(String::as_str) + } + + fn filtered(mut self, names: &[String]) -> Self { + self.functions.retain(|name, _| names.contains(name)); + self + } + /// Register a typed host function that can be called from the guest /// JavaScript code. /// @@ -155,7 +241,7 @@ impl HostModule { func: impl Fn(JsonValue, Vec>) -> crate::Result + Send + Sync + 'static, ) -> &mut Self { self.functions - .insert(name.into(), HostFn::JsBridge(std::sync::Arc::new(func))); + .insert(name.into(), HostFn::JsBridge(Box::new(func))); self } @@ -222,10 +308,120 @@ impl HostModule { } } +/// A named collection of host functions exposed to one JavaScript sandbox. +/// +/// Host-function modules execute in the host process through +/// `CallHostJsFunction`. They are distinct from native modules compiled into the +/// guest runtime and JavaScript source modules executed inside the guest. Define +/// a constructor function when multiple sandboxes need equivalent modules; each +/// sandbox must receive a newly constructed module and callback implementation. +pub struct HostFunctionModule { + name: String, + module: HostModule, +} + +impl HostFunctionModule { + /// Create an empty host-function module with the supplied import name. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + module: HostModule::default(), + } + } + + /// Return the exact module name visible to guest JavaScript. + pub fn name(&self) -> &str { + &self.name + } + + /// Register a typed host function on this module. + pub fn register( + &mut self, + name: impl Into, + func: impl Function + Send + Sync + 'static, + ) -> &mut Self { + self.module.register(name, func); + self + } + + /// Register a JavaScript-bridge host function on this module. + #[doc(hidden)] + pub fn register_js( + &mut self, + name: impl Into, + func: impl Fn(JsonValue, Vec>) -> crate::Result + Send + Sync + 'static, + ) -> &mut Self { + self.module.register_js(name, func); + self + } +} + +impl std::fmt::Debug for HostFunctionModule { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("HostFunctionModule") + .field("name", &self.name) + .field( + "functions", + &self.module.function_names().collect::>(), + ) + .finish() + } +} + +pub(crate) fn select_host_function_modules( + modules: Vec, + filter: HostFunctionFilter, + requirements: Option<&HostFunctionManifest>, +) -> crate::Result> { + if filter == HostFunctionFilter::SnapshotRequirements && requirements.is_none() { + return Err(crate::new_error!( + "HostFunctionFilter::SnapshotRequirements requires build_from_snapshot()" + )); + } + + let mut selected = HashMap::with_capacity(modules.len()); + for module in modules { + let HostFunctionModule { name, module } = module; + if selected.contains_key(&name) { + return Err(crate::new_error!( + "Duplicate host-function module '{}'", + name + )); + } + + let functions = match filter { + HostFunctionFilter::All => module, + HostFunctionFilter::SnapshotRequirements => { + let Some(requirements) = requirements else { + return Err(crate::new_error!( + "HostFunctionFilter::SnapshotRequirements requires build_from_snapshot()" + )); + }; + let Some(names) = requirements.get(&name) else { + continue; + }; + module.filtered(names) + } + }; + selected.insert(name, functions); + } + Ok(selected) +} + #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use super::*; + fn stateful_modules() -> Vec { + let calls = AtomicUsize::new(0); + let mut module = HostFunctionModule::new("state"); + module.register("next", move || calls.fetch_add(1, Ordering::Relaxed) + 1); + vec![module] + } + #[test] fn call_typed_no_binaries() { let mut module = HostModule::default(); @@ -240,6 +436,106 @@ mod tests { assert_eq!(&result[1..], b"7"); } + #[test] + fn modules_select_all_functions() { + let mut module = HostFunctionModule::new("math"); + module + .register("add", |a: i32, b: i32| a + b) + .register("multiply", |a: i32, b: i32| a * b); + + let selected = + select_host_function_modules(vec![module], HostFunctionFilter::All, None).unwrap(); + + assert_eq!( + host_function_manifest(&selected).get("math"), + Some(&vec!["add".to_owned(), "multiply".to_owned()]) + ); + } + + #[test] + fn repeated_factory_calls_create_independent_callback_state() { + let first = select_host_function_modules(stateful_modules(), HostFunctionFilter::All, None) + .unwrap() + .remove("state") + .unwrap(); + let second = + select_host_function_modules(stateful_modules(), HostFunctionFilter::All, None) + .unwrap() + .remove("state") + .unwrap(); + + let first_call = hyperlight_js_common::encode_json_return("1"); + let second_call = hyperlight_js_common::encode_json_return("2"); + + assert_eq!( + first.call("next", "null".to_owned(), None).unwrap(), + first_call + ); + assert_eq!( + first.call("next", "null".to_owned(), None).unwrap(), + second_call + ); + assert_eq!( + second.call("next", "null".to_owned(), None).unwrap(), + first_call + ); + } + + #[test] + fn modules_select_snapshot_requirements() { + let mut module = HostFunctionModule::new("math"); + module + .register("add", |a: i32, b: i32| a + b) + .register("multiply", |a: i32, b: i32| a * b); + let requirements = + HostFunctionManifest::from([("math".to_owned(), vec!["multiply".to_owned()])]); + + let selected = select_host_function_modules( + vec![module], + HostFunctionFilter::SnapshotRequirements, + Some(&requirements), + ) + .unwrap(); + + assert_eq!( + host_function_manifest(&selected).get("math"), + Some(&vec!["multiply".to_owned()]) + ); + } + + #[test] + fn snapshot_filter_rejects_fresh_builds() { + let result = select_host_function_modules( + Vec::new(), + HostFunctionFilter::SnapshotRequirements, + None, + ); + let error = match result { + Ok(_) => panic!("snapshot filtering unexpectedly accepted a fresh build"), + Err(error) => error, + }; + + assert!(error.to_string().contains("requires build_from_snapshot()")); + } + + #[test] + fn duplicate_module_names_are_rejected() { + let modules = vec![ + HostFunctionModule::new("math"), + HostFunctionModule::new("math"), + ]; + + let result = select_host_function_modules(modules, HostFunctionFilter::All, None); + let error = match result { + Ok(_) => panic!("duplicate host-function modules were unexpectedly accepted"), + Err(error) => error, + }; + + assert!(error + .to_string() + .contains("Duplicate host-function module 'math'")); + } + #[test] fn call_typed_rejects_binary_args() { let mut module = HostModule::default(); diff --git a/src/hyperlight-js/src/sandbox/js_sandbox.rs b/src/hyperlight-js/src/sandbox/js_sandbox.rs index 1d224dd1..f7bcdb34 100644 --- a/src/hyperlight-js/src/sandbox/js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/js_sandbox.rs @@ -17,11 +17,15 @@ use std::collections::HashMap; use std::fmt::Debug; use std::sync::Arc; -use hyperlight_host::sandbox::snapshot::Snapshot; +use hyperlight_host::sandbox::snapshot::Snapshot as HyperlightSnapshot; use hyperlight_host::{new_error, MultiUseSandbox, Result, SandboxStatus}; use tracing::{instrument, Level}; +use super::host_fn::register_host_function_manifest; use super::loaded_js_sandbox::LoadedJSSandbox; +use super::snapshot::{ + HostFunctionManifest, ScriptMetadata, Snapshot, SnapshotKind, SnapshotMetadata, +}; use crate::sandbox::metrics::SandboxMetricsGuard; use crate::Script; @@ -73,24 +77,32 @@ pub fn validate_namespace_not_reserved(namespace: &str) -> Result<()> { /// A Hyperlight Sandbox with a JavaScript run time loaded but no guest code. pub struct JSSandbox { pub(super) inner: MultiUseSandbox, + // Mutable definitions staged for guest registration. Snapshot metadata is + // built from these maps only when a snapshot or LoadedJSSandbox is created. handlers: HashMap, /// User modules keyed by qualified name (e.g. `user:utils`). modules: HashMap, + // Host functions selected by SandboxBuilder. + host_functions: HostFunctionManifest, // Snapshot of state before any handlers are added. // This is used to restore state back to a neutral JSSandbox. - snapshot: Arc, + snapshot: Arc, // metric drop guard to manage sandbox metric _metric_guard: SandboxMetricsGuard, } impl JSSandbox { #[instrument(err(Debug), skip(inner), level=Level::INFO)] - pub(super) fn new(mut inner: MultiUseSandbox) -> Result { + pub(super) fn new( + mut inner: MultiUseSandbox, + host_functions: HostFunctionManifest, + ) -> Result { let snapshot = inner.snapshot()?; Ok(Self { inner, handlers: HashMap::new(), modules: HashMap::new(), + host_functions, snapshot, _metric_guard: SandboxMetricsGuard::new(), }) @@ -99,18 +111,81 @@ impl JSSandbox { /// Creates a new `JSSandbox` from a `MultiUseSandbox` and a `Snapshot` of state before any handlers were added. pub(crate) fn from_loaded( mut loaded: MultiUseSandbox, - snapshot: Arc, + snapshot: Arc, + host_functions: HostFunctionManifest, ) -> Result { loaded.restore(snapshot.clone())?; + register_host_function_manifest(&mut loaded, &host_functions)?; Ok(Self { inner: loaded, handlers: HashMap::new(), modules: HashMap::new(), + host_functions, snapshot, _metric_guard: SandboxMetricsGuard::new(), }) } + pub(crate) fn from_snapshot( + inner: MultiUseSandbox, + snapshot: &Snapshot, + host_functions: HostFunctionManifest, + ) -> Result { + if snapshot.kind() != SnapshotKind::JsSandbox { + return Err(new_error!( + "Cannot restore a {:?} snapshot as a JSSandbox", + snapshot.kind() + )); + } + + Ok(Self { + inner, + handlers: snapshot.metadata().handlers().collect(), + modules: snapshot.metadata().modules().collect(), + host_functions, + snapshot: snapshot.inner(), + _metric_guard: SandboxMetricsGuard::new(), + }) + } + + /// Capture the current runtime state and handlers and modules added to this + /// sandbox but not yet registered in the guest. + #[instrument(err(Debug), skip_all, level=Level::DEBUG)] + pub fn snapshot(&mut self) -> Result { + let inner = self.inner.snapshot()?; + let metadata = SnapshotMetadata::new( + SnapshotKind::JsSandbox, + self.handlers + .iter() + .map(|(name, script)| (name.clone(), ScriptMetadata::from(script))), + self.modules + .iter() + .map(|(name, script)| (name.clone(), ScriptMetadata::from(script))), + self.host_functions.clone(), + ); + Ok(Snapshot::new(inner, None, Arc::new(metadata))) + } + + /// Restore the runtime state and handlers and modules that were added to the + /// snapshotted sandbox but not yet registered in the guest. + #[instrument(err(Debug), skip_all, level=Level::DEBUG)] + pub fn restore(&mut self, snapshot: Snapshot) -> Result<()> { + if snapshot.kind() != SnapshotKind::JsSandbox { + return Err(new_error!( + "Cannot restore a {:?} snapshot into a JSSandbox", + snapshot.kind() + )); + } + snapshot + .metadata() + .validate_host_functions(&self.host_functions)?; + self.inner.restore(snapshot.inner())?; + self.handlers = snapshot.metadata().handlers().collect(); + self.modules = snapshot.metadata().modules().collect(); + self.snapshot = snapshot.inner(); + Ok(()) + } + /// Adds a new handler function to the sandboxes collection of handlers. This Handler will be /// available to the host to call once `get_loaded_sandbox` is called. #[instrument(err(Debug), skip(self, script), level=Level::DEBUG)] @@ -311,6 +386,17 @@ impl JSSandbox { return Err(new_error!("No handlers have been added to the sandbox")); } + let metadata = Arc::new(SnapshotMetadata::new( + SnapshotKind::LoadedJsSandbox, + std::iter::empty(), + std::iter::empty(), + self.host_functions.clone(), + )); + + // Publish the builder-selected host functions before compiling modules + // and handlers that may import them. + register_host_function_manifest(&mut self.inner, &self.host_functions)?; + // Register user modules first so that handlers can import them. // NOTE: HashMap iteration order is non-deterministic, but this is safe // because modules are lazily compiled by the UserModuleLoader when first @@ -332,7 +418,7 @@ impl JSSandbox { .call::<()>("register_handler", (function_name, content, path))?; } - LoadedJSSandbox::new(self.inner, self.snapshot) + LoadedJSSandbox::new(self.inner, self.snapshot, metadata, self.host_functions) } /// Generate a crash dump of the current state of the VM underlying this sandbox. /// diff --git a/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs b/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs index 2958c783..11275399 100644 --- a/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs @@ -17,7 +17,7 @@ use std::fmt::Debug; use std::sync::Arc; use hyperlight_host::hypervisor::InterruptHandle; -use hyperlight_host::sandbox::snapshot::Snapshot; +use hyperlight_host::sandbox::snapshot::Snapshot as HyperlightSnapshot; use hyperlight_host::HyperlightError::{self, JsonConversionFailure}; use hyperlight_host::{MultiUseSandbox, Result, SandboxStatus}; use tokio::task::JoinHandle; @@ -29,6 +29,7 @@ use super::js_sandbox::JSSandbox; use super::metrics::{METRIC_SANDBOX_LOADS, METRIC_SANDBOX_UNLOADS}; use super::monitor::runtime::get_monitor_runtime; use super::monitor::MonitorSet; +use super::snapshot::{HostFunctionManifest, Snapshot, SnapshotKind, SnapshotMetadata}; #[cfg(feature = "function_call_metrics")] use crate::sandbox::metrics::EventHandlerMetricGuard; use crate::sandbox::metrics::SandboxMetricsGuard; @@ -38,7 +39,13 @@ pub struct LoadedJSSandbox { inner: MultiUseSandbox, // Snapshot of state before the sandbox was loaded and before any handlers were added. // This is used to restore state back to a JSSandbox. - snapshot: Arc, + snapshot: Arc, + // Metadata for the currently captured loaded guest state. Its host-function + // manifest records only the capabilities required by that state. + snapshot_metadata: Arc, + // Builder-selected callbacks available to host dispatch. These become the + // capability manifest if this sandbox is unloaded back to a JSSandbox. + available_host_functions: HostFunctionManifest, // metric drop guard to manage sandbox metric _metric_guard: SandboxMetricsGuard, // Stats from the most recent handle_event / handle_event_with_monitor call. @@ -63,17 +70,46 @@ impl Drop for MonitorTask { impl LoadedJSSandbox { #[instrument(err(Debug), skip_all, level=Level::INFO)] - pub(super) fn new(inner: MultiUseSandbox, snapshot: Arc) -> Result { + pub(super) fn new( + inner: MultiUseSandbox, + snapshot: Arc, + snapshot_metadata: Arc, + available_host_functions: HostFunctionManifest, + ) -> Result { metrics::counter!(METRIC_SANDBOX_LOADS).increment(1); Ok(LoadedJSSandbox { inner, snapshot, + snapshot_metadata, + available_host_functions, _metric_guard: SandboxMetricsGuard::new(), #[cfg(feature = "guest-call-stats")] last_call_stats: None, }) } + pub(crate) fn from_snapshot( + inner: MultiUseSandbox, + snapshot: &Snapshot, + available_host_functions: HostFunctionManifest, + ) -> Result { + if snapshot.kind() != SnapshotKind::LoadedJsSandbox { + return Err(crate::new_error!( + "Cannot restore a {:?} snapshot as a LoadedJSSandbox", + snapshot.kind() + )); + } + let base = snapshot.base().ok_or_else(|| { + crate::new_error!("LoadedJSSandbox snapshot is missing its base snapshot") + })?; + Self::new( + inner, + base, + Arc::new(snapshot.metadata().clone()), + available_host_functions, + ) + } + /// Handles an event by calling the specified function with the event data. #[instrument(err(Debug), skip(self, event, gc), level=Level::INFO)] pub fn handle_event( @@ -167,22 +203,39 @@ impl LoadedJSSandbox { /// Unloads the Handlers from the sandbox and returns a `JSSandbox` with the JavaScript runtime loaded. #[instrument(err(Debug), skip_all, level=Level::DEBUG)] pub fn unload(self) -> Result { - JSSandbox::from_loaded(self.inner, self.snapshot).inspect(|_| { - metrics::counter!(METRIC_SANDBOX_UNLOADS).increment(1); - }) + JSSandbox::from_loaded(self.inner, self.snapshot, self.available_host_functions) + .inspect(|_| metrics::counter!(METRIC_SANDBOX_UNLOADS).increment(1)) } /// Take a snapshot of the the current state of the sandbox. /// This can be used to restore the state of the sandbox later. #[instrument(err(Debug), skip_all, level=Level::DEBUG)] - pub fn snapshot(&mut self) -> Result> { - self.inner.snapshot() + pub fn snapshot(&mut self) -> Result { + let inner = self.inner.snapshot()?; + Ok(Snapshot::new( + inner, + Some(Arc::clone(&self.snapshot)), + Arc::clone(&self.snapshot_metadata), + )) } /// Restore the state of the sandbox to a previous snapshot. #[instrument(err(Debug), skip_all, level=Level::DEBUG)] - pub fn restore(&mut self, snapshot: Arc) -> Result<()> { - self.inner.restore(snapshot)?; + pub fn restore(&mut self, snapshot: Snapshot) -> Result<()> { + if snapshot.kind() != SnapshotKind::LoadedJsSandbox { + return Err(crate::new_error!( + "Cannot restore a {:?} snapshot into a LoadedJSSandbox", + snapshot.kind() + )); + } + snapshot + .metadata() + .validate_host_functions(&self.available_host_functions)?; + self.inner.restore(snapshot.inner())?; + self.snapshot = snapshot.base().ok_or_else(|| { + crate::new_error!("LoadedJSSandbox snapshot is missing its base snapshot") + })?; + self.snapshot_metadata = Arc::new(snapshot.metadata().clone()); Ok(()) } diff --git a/src/hyperlight-js/src/sandbox/mod.rs b/src/hyperlight-js/src/sandbox/mod.rs index 2d6893c8..87eaa2a1 100644 --- a/src/hyperlight-js/src/sandbox/mod.rs +++ b/src/hyperlight-js/src/sandbox/mod.rs @@ -26,6 +26,8 @@ pub(crate) mod js_sandbox; pub(crate) mod loaded_js_sandbox; /// Metric definitions for Sandbox module. pub(crate) mod metrics; +/// External JavaScript module-loader host resources. +pub(crate) mod module_loader; /// Execution monitoring and enforcement (timeouts, resource limits, etc.). pub mod monitor; /// A Hyperlight Sandbox with no JavaScript run time loaded and no guest code. @@ -33,6 +35,10 @@ pub mod monitor; pub(crate) mod proto_js_sandbox; /// A builder for creating a new `JSSandbox` pub(crate) mod sandbox_builder; +/// Restores a Hyperlight JavaScript sandbox from a snapshot. +pub(crate) mod sandbox_restorer; +/// Persistent Hyperlight JavaScript snapshots. +pub(crate) mod snapshot; // This include! macro is replaced by the build.rs script. // The build.rs script reads the hyperlight-js-runtime binary into a static byte array named JSRUNTIME. include!(concat!(env!("OUT_DIR"), "/host_resource.rs")); diff --git a/src/hyperlight-js/src/sandbox/module_loader.rs b/src/hyperlight-js/src/sandbox/module_loader.rs new file mode 100644 index 00000000..1570fd28 --- /dev/null +++ b/src/hyperlight-js/src/sandbox/module_loader.rs @@ -0,0 +1,84 @@ +/* +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. +*/ + +use std::path::PathBuf; + +use hyperlight_host::{ + new_error, Result, SandboxBuilder as HyperlightSandboxBuilder, UninitializedSandbox, +}; +use oxc_resolver::{ResolveOptions, ResolverGeneric}; + +pub(super) const RESOLVE_MODULE_HOST_FUNCTION: &str = "ResolveModule"; +pub(super) const LOAD_MODULE_HOST_FUNCTION: &str = "LoadModule"; + +type ResolveModule = Box Result + Send + Sync>; +type LoadModule = Box Result + Send + Sync>; + +pub(super) struct ExternalModuleLoader { + resolve: ResolveModule, + load: LoadModule, +} + +impl ExternalModuleLoader { + pub(super) fn new(file_system: Fs) -> Self { + let resolver = ResolverGeneric::new_with_file_system( + file_system.clone(), + ResolveOptions { + extensions: vec![".js".into(), ".mjs".into()], + condition_names: vec!["import".into(), "module".into()], + ..Default::default() + }, + ); + let resolve = Box::new(move |base: String, specifier: String| { + let resolved = resolver.resolve(&base, &specifier).map_err(|error| { + new_error!( + "Failed to resolve module '{}' from '{}': {:?}", + specifier, + base, + error + ) + })?; + Ok(resolved.path().to_string_lossy().to_string()) + }); + let load = Box::new(move |path: String| { + file_system + .read_to_string(&PathBuf::from(&path)) + .map_err(|error| new_error!("Failed to read module '{}': {}", path, error)) + }); + Self { resolve, load } + } + + pub(super) fn register(self, sandbox: &mut UninitializedSandbox) -> Result<()> { + sandbox.register( + RESOLVE_MODULE_HOST_FUNCTION, + move |base: String, specifier: String| (self.resolve)(base, specifier), + )?; + sandbox.register(LOAD_MODULE_HOST_FUNCTION, move |path: String| { + (self.load)(path) + })?; + Ok(()) + } + + pub(super) fn apply(self, builder: HyperlightSandboxBuilder) -> HyperlightSandboxBuilder { + let builder = builder.host_function( + RESOLVE_MODULE_HOST_FUNCTION, + move |base: String, specifier: String| (self.resolve)(base, specifier), + ); + builder.host_function(LOAD_MODULE_HOST_FUNCTION, move |path: String| { + (self.load)(path) + }) + } +} diff --git a/src/hyperlight-js/src/sandbox/proto_js_sandbox.rs b/src/hyperlight-js/src/sandbox/proto_js_sandbox.rs index bce9c85e..a56e0364 100644 --- a/src/hyperlight-js/src/sandbox/proto_js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/proto_js_sandbox.rs @@ -19,19 +19,31 @@ use std::time::SystemTime; use anyhow::Context; use hyperlight_host::sandbox::SandboxConfiguration; -use hyperlight_host::{new_error, GuestBinary, Result, UninitializedSandbox}; +use hyperlight_host::{GuestBinary, Result, UninitializedSandbox}; use serde::de::DeserializeOwned; use serde::Serialize; use tracing::{instrument, Level}; use super::js_sandbox::JSSandbox; use super::sandbox_builder::SandboxBuilder; -use crate::sandbox::host_fn::{Function, HostModule}; +use crate::sandbox::host_fn::{ + host_function_dispatch, host_function_manifest, Function, HostModule, +}; use crate::sandbox::metrics::SandboxMetricsGuard; +use crate::sandbox::module_loader::ExternalModuleLoader; use crate::HostPrintFn; -/// A Hyperlight Sandbox with no JavaScript run time loaded and no guest code. -/// This is used to register new host functions prior to loading the JavaScript run time. +pub(super) fn current_time_micros() -> hyperlight_host::Result { + Ok(SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .with_context(|| "Unable to get duration since epoch") + .map(|duration| duration.as_micros() as u64)?) +} + +/// A fresh Hyperlight sandbox before the JavaScript runtime is loaded. +/// +/// Register host resources, then call [`Self::load_runtime`] to create a +/// [`JSSandbox`]. Snapshot restoration uses [`crate::SandboxRestorer`] instead. pub struct ProtoJSSandbox { inner: UninitializedSandbox, host_modules: HashMap, @@ -45,6 +57,8 @@ impl ProtoJSSandbox { guest_binary: GuestBinary, cfg: Option, host_print_writer: Option, + host_modules: HashMap, + module_loader: Option, ) -> Result { let mut usbox: UninitializedSandbox = UninitializedSandbox::new(guest_binary, cfg)?; @@ -53,19 +67,14 @@ impl ProtoJSSandbox { usbox.register_print(host_print_writer)?; } - // host function used by rquickjs for Date.now() - fn current_time_micros() -> hyperlight_host::Result { - Ok(SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .with_context(|| "Unable to get duration since epoch") - .map(|d| d.as_micros() as u64)?) - } - usbox.register("CurrentTimeMicros", current_time_micros)?; + if let Some(module_loader) = module_loader { + module_loader.register(&mut usbox)?; + } Ok(Self { inner: usbox, - host_modules: HashMap::new(), + host_modules, _metric_guard: SandboxMetricsGuard::new(), }) } @@ -78,95 +87,28 @@ impl ProtoJSSandbox { mut self, file_system: Fs, ) -> Result { - use std::path::PathBuf; - - use oxc_resolver::{ResolveOptions, ResolverGeneric}; - - let resolver = ResolverGeneric::new_with_file_system( - file_system.clone(), - ResolveOptions { - extensions: vec![".js".into(), ".mjs".into()], - condition_names: vec!["import".into(), "module".into()], - ..Default::default() - }, - ); - - self.inner.register( - "ResolveModule", - move |base: String, specifier: String| -> hyperlight_host::Result { - tracing::debug!( - base = %base, - specifier = %specifier, - "Resolving module" - ); - - let resolved = resolver.resolve(&base, &specifier).map_err(|e| { - new_error!( - "Failed to resolve module '{}' from '{}': {:?}", - specifier, - base, - e - ) - })?; - - Ok(resolved.path().to_string_lossy().to_string()) - }, - )?; - - self.inner.register( - "LoadModule", - move |path: String| -> hyperlight_host::Result { - tracing::debug!(path = %path, "Loading module"); - let path_buf = PathBuf::from(&path); - let source = file_system - .read_to_string(&path_buf) - .map_err(|e| new_error!("Failed to read module '{}': {}", path, e))?; - - Ok(source) - }, - )?; - + ExternalModuleLoader::new(file_system).register(&mut self.inner)?; Ok(self) } /// Load the JavaScript runtime into the sandbox. #[instrument(err(Debug), skip(self), level=Level::INFO)] pub fn load_runtime(mut self) -> Result { + let host_functions = host_function_manifest(&self.host_modules); + let host_modules_json = serde_json::to_string(&self.host_modules)?; let host_modules = self.host_modules; - let host_modules_json = serde_json::to_string(&host_modules)?; - // Register the host function that the guest calls for all host // function invocations. Binary data (if any) is carried in a // length-prefixed sidecar alongside the JSON args. - self.inner.register( - "CallHostJsFunction", - move |module_name: String, - func_name: String, - args_json: String, - binaries: Vec| - -> Result> { - let module = host_modules - .get(&module_name) - .ok_or_else(|| new_error!("Host module '{}' not found", module_name))?; - module - .call(&func_name, args_json, Some(binaries)) - .map_err(|e| { - new_error!( - "Error calling host function '{}' in module '{}': {}", - func_name, - module_name, - e - ) - }) - }, - )?; + self.inner + .register("CallHostJsFunction", host_function_dispatch(host_modules))?; let mut multi_use_sandbox = self.inner.evolve()?; let _: () = multi_use_sandbox.call("RegisterHostModules", host_modules_json)?; - JSSandbox::new(multi_use_sandbox) + JSSandbox::new(multi_use_sandbox, host_functions) } /// Register a host module that can be called from the guest JavaScript code. diff --git a/src/hyperlight-js/src/sandbox/sandbox_builder.rs b/src/hyperlight-js/src/sandbox/sandbox_builder.rs index 00f48928..969f3651 100644 --- a/src/hyperlight-js/src/sandbox/sandbox_builder.rs +++ b/src/hyperlight-js/src/sandbox/sandbox_builder.rs @@ -19,13 +19,20 @@ use std::time::Duration; use hyperlight_host::sandbox::SandboxConfiguration; use hyperlight_host::{is_hypervisor_present, GuestBinary, HyperlightError, Result}; +use super::host_fn::{select_host_function_modules, HostFunctionFilter, HostFunctionModule}; +use super::module_loader::ExternalModuleLoader; use super::proto_js_sandbox::ProtoJSSandbox; +use super::sandbox_restorer::SandboxRestorer; +use super::snapshot::Snapshot; use crate::HostPrintFn; -/// A builder for a ProtoJSSandbox +/// Configures fresh JavaScript sandboxes and snapshot restoration. pub struct SandboxBuilder { config: SandboxConfiguration, host_print_fn: Option, + host_function_modules: Vec, + host_function_filter: HostFunctionFilter, + module_loader: Option, } /// The minimum scratch size for the JS runtime sandbox. @@ -61,9 +68,59 @@ impl SandboxBuilder { Self { config, host_print_fn: None, + host_function_modules: Vec::new(), + host_function_filter: HostFunctionFilter::All, + module_loader: None, } } + /// Define the host-function modules for this sandbox. + /// + /// The factory is invoked once and must construct fresh module and callback + /// instances. Pass the same factory to another builder to reuse the + /// definition without sharing callback state between sandboxes. + /// + /// Create per-sandbox state inside the factory. Capturing an [`std::sync::Arc`] + /// created outside the factory, or accessing static mutable state from a + /// callback, deliberately shares that state between sandboxes. + /// + /// A constructed module cannot be supplied to two builders: + /// + /// ```compile_fail + /// use hyperlight_js::{HostFunctionModule, SandboxBuilder}; + /// + /// let module = HostFunctionModule::new("host:example"); + /// let _first = SandboxBuilder::new().with_host_function_modules(|| [module]); + /// let _second = SandboxBuilder::new().with_host_function_modules(|| [module]); + /// ``` + pub fn with_host_function_modules(mut self, factory: Factory) -> Self + where + Factory: FnOnce() -> Modules, + Modules: IntoIterator, + { + self.host_function_modules.extend(factory()); + self + } + + /// Select which supplied host functions remain available during restoration. + /// + /// Loaded snapshots keep their captured requirements unchanged. With + /// [`HostFunctionFilter::All`], extra supplied callbacks become available + /// after unload; [`HostFunctionFilter::SnapshotRequirements`] discards them. + pub fn with_host_function_filter(mut self, filter: HostFunctionFilter) -> Self { + self.host_function_filter = filter; + self + } + + /// Install an external JavaScript module loader for fresh or restored sandboxes. + pub fn with_module_loader( + mut self, + file_system: Fs, + ) -> Self { + self.module_loader = Some(ExternalModuleLoader::new(file_system)); + self + } + /// Set the host print function pub fn with_host_print_fn(mut self, host_print_fn: HostPrintFn) -> Self { self.host_print_fn = Some(host_print_fn); @@ -72,7 +129,7 @@ impl SandboxBuilder { /// Set the guest output buffer size pub fn with_guest_output_buffer_size(mut self, guest_output_buffer_size: usize) -> Self { - self.config.set_output_data_size(guest_output_buffer_size); + self.config.set_g2h_buffer_size(guest_output_buffer_size); self } @@ -82,7 +139,7 @@ impl SandboxBuilder { /// The host can read from this buffer /// The guest can write to this buffer pub fn with_guest_input_buffer_size(mut self, guest_input_buffer_size: usize) -> Self { - self.config.set_input_data_size(guest_input_buffer_size); + self.config.set_h2g_buffer_size(guest_input_buffer_size); self } @@ -175,11 +232,43 @@ impl SandboxBuilder { if !is_hypervisor_present() { return Err(HyperlightError::NoHypervisorFound()); } + let host_modules = select_host_function_modules( + self.host_function_modules, + self.host_function_filter, + None, + )?; let guest_binary = GuestBinary::Buffer(super::JSRUNTIME.to_vec()); - let proto_js_sandbox = - ProtoJSSandbox::new(guest_binary, Some(self.config), self.host_print_fn)?; + let proto_js_sandbox = ProtoJSSandbox::new( + guest_binary, + Some(self.config), + self.host_print_fn, + host_modules, + self.module_loader, + )?; Ok(proto_js_sandbox) } + + /// Create a restorer for a Hyperlight JavaScript snapshot. + /// + /// Configure host resources on this builder, then call + /// [`SandboxRestorer::restore`] with the intended sandbox type. + pub fn build_from_snapshot(self, snapshot: Snapshot) -> Result { + if !is_hypervisor_present() { + return Err(HyperlightError::NoHypervisorFound()); + } + let requirements = snapshot.requirements(); + let host_modules = select_host_function_modules( + self.host_function_modules, + self.host_function_filter, + Some(requirements.host_functions()), + )?; + SandboxRestorer::new( + snapshot, + self.host_print_fn, + host_modules, + self.module_loader, + ) + } } impl Default for SandboxBuilder { diff --git a/src/hyperlight-js/src/sandbox/sandbox_restorer.rs b/src/hyperlight-js/src/sandbox/sandbox_restorer.rs new file mode 100644 index 00000000..42fedbca --- /dev/null +++ b/src/hyperlight-js/src/sandbox/sandbox_restorer.rs @@ -0,0 +1,147 @@ +/* +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. +*/ + +use std::collections::HashMap; + +use hyperlight_host::{new_error, Result, SandboxBuilder as HyperlightSandboxBuilder}; + +use super::host_fn::{host_function_dispatch, host_function_manifest, HostModule}; +use super::js_sandbox::JSSandbox; +use super::loaded_js_sandbox::LoadedJSSandbox; +use super::module_loader::{ + ExternalModuleLoader, LOAD_MODULE_HOST_FUNCTION, RESOLVE_MODULE_HOST_FUNCTION, +}; +use super::proto_js_sandbox::current_time_micros; +use super::snapshot::{HostFunctionManifest, Snapshot, SnapshotKind, SnapshotRequirements}; +use crate::HostPrintFn; + +mod private { + pub trait Sealed {} +} + +/// A sandbox type that can be restored by [`SandboxRestorer::restore`]. +/// +/// This trait is sealed and implemented only for [`JSSandbox`] and +/// [`LoadedJSSandbox`]. +pub trait RestoreTarget: private::Sealed + Sized { + /// Restore this concrete sandbox type from the configured restorer. + #[doc(hidden)] + fn restore_from(restorer: SandboxRestorer) -> Result; +} + +/// Configures process-local host resources before restoring a snapshot. +pub struct SandboxRestorer { + builder: HyperlightSandboxBuilder, + snapshot: Snapshot, + host_modules: HashMap, +} + +impl SandboxRestorer { + pub(super) fn new( + snapshot: Snapshot, + host_print_writer: Option, + host_modules: HashMap, + module_loader: Option, + ) -> Result { + let mut builder = HyperlightSandboxBuilder::from_snapshot(snapshot.inner()); + if let Some(host_print_writer) = host_print_writer { + builder = builder.host_print(host_print_writer); + } + builder = builder.host_function("CurrentTimeMicros", current_time_micros); + if let Some(module_loader) = module_loader { + builder = module_loader.apply(builder); + } + + Ok(Self { + builder, + snapshot, + host_modules, + }) + } + + /// Return the lifecycle kind captured by the snapshot. + pub fn kind(&self) -> SnapshotKind { + self.snapshot.kind() + } + + /// Return the host resources required by the snapshot. + pub fn requirements(&self) -> SnapshotRequirements { + self.snapshot.requirements() + } + + /// Restore the snapshot as the requested sandbox type. + pub fn restore(self) -> Result { + T::restore_from(self) + } + + fn build(self) -> Result<(hyperlight_host::MultiUseSandbox, HostFunctionManifest)> { + let available_host_functions = host_function_manifest(&self.host_modules); + self.snapshot + .metadata() + .validate_host_functions(&available_host_functions)?; + + let builder = self.builder.host_function( + "CallHostJsFunction", + host_function_dispatch(self.host_modules), + ); + let sandbox = builder.build().map_err(|error| match &error { + hyperlight_host::HyperlightError::SnapshotHostFunctionMismatch { missing, .. } + if missing.iter().any(|name| { + name == RESOLVE_MODULE_HOST_FUNCTION || name == LOAD_MODULE_HOST_FUNCTION + }) => + { + new_error!( + "Snapshot requires a module loader; call with_module_loader() on SandboxBuilder before build_from_snapshot() ({})", + error + ) + } + _ => error, + })?; + Ok((sandbox, available_host_functions)) + } +} + +impl private::Sealed for JSSandbox {} + +impl RestoreTarget for JSSandbox { + fn restore_from(restorer: SandboxRestorer) -> Result { + if restorer.snapshot.kind() != SnapshotKind::JsSandbox { + return Err(new_error!( + "Cannot restore a {:?} snapshot as a JSSandbox", + restorer.snapshot.kind() + )); + } + let snapshot = restorer.snapshot.clone(); + let (sandbox, available_host_functions) = restorer.build()?; + JSSandbox::from_snapshot(sandbox, &snapshot, available_host_functions) + } +} + +impl private::Sealed for LoadedJSSandbox {} + +impl RestoreTarget for LoadedJSSandbox { + fn restore_from(restorer: SandboxRestorer) -> Result { + if restorer.snapshot.kind() != SnapshotKind::LoadedJsSandbox { + return Err(new_error!( + "Cannot restore a {:?} snapshot as a LoadedJSSandbox", + restorer.snapshot.kind() + )); + } + let snapshot = restorer.snapshot.clone(); + let (sandbox, available_host_functions) = restorer.build()?; + LoadedJSSandbox::from_snapshot(sandbox, &snapshot, available_host_functions) + } +} diff --git a/src/hyperlight-js/src/sandbox/snapshot.rs b/src/hyperlight-js/src/sandbox/snapshot.rs new file mode 100644 index 00000000..70ce0740 --- /dev/null +++ b/src/hyperlight-js/src/sandbox/snapshot.rs @@ -0,0 +1,534 @@ +/* +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. +*/ + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use hyperlight_host::sandbox::snapshot::{ + OciDigest, OciReference, OciTag, Snapshot as HyperlightSnapshot, +}; +use serde::{Deserialize, Serialize}; + +use super::js_sandbox::{validate_module_identifier, validate_namespace_not_reserved}; +use crate::{new_error, Result, Script}; + +const METADATA_NAMESPACE: &str = "dev.hyperlight.hyperlight-js.snapshot.v1"; +const METADATA_SCHEMA_VERSION: u32 = 1; +const BASE_TAG_SUFFIX: &str = ".hljs-base"; +const MAX_OCI_TAG_LENGTH: usize = 128; +const MAX_LOADED_SNAPSHOT_TAG_LENGTH: usize = MAX_OCI_TAG_LENGTH - BASE_TAG_SUFFIX.len(); + +/// Logical JavaScript host modules and exported function names. +/// +/// Signatures are intentionally not represented. Typed Rust callbacks are +/// erased to a JSON bridge, JavaScript callbacks are dynamic, and Hyperlight +/// can validate only the fixed `CallHostJsFunction` bridge signature. This +/// manifest therefore guarantees name availability, not semantic signature +/// compatibility; callers must keep implementations compatible across restore. +pub(crate) type HostFunctionManifest = BTreeMap>; + +/// The Hyperlight JavaScript lifecycle state captured by a [`Snapshot`]. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SnapshotKind { + /// The JavaScript runtime is loaded, with handlers and modules added to the + /// `JSSandbox` but not yet registered in the guest. + JsSandbox, + /// The JavaScript runtime, handlers, and modules are loaded in the guest. + LoadedJsSandbox, +} + +/// Whether a host resource is required by a snapshot. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RequirementStatus { + /// The underlying snapshot API does not expose this requirement yet. + Unknown, + /// The resource is required to restore the snapshot. + Required, + /// The resource is not required to restore the snapshot. + NotRequired, +} + +/// Host resources required to restore a [`Snapshot`]. +/// +/// This type is non-exhaustive so additional requirements exposed by +/// Hyperlight can be added without changing the existing API. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct SnapshotRequirements { + host_functions: HostFunctionManifest, + module_loader: RequirementStatus, +} + +impl SnapshotRequirements { + /// Required Hyperlight-JS host functions grouped by qualified module name. + pub fn host_functions(&self) -> &BTreeMap> { + &self.host_functions + } + + /// Whether the snapshot requires a custom JavaScript module loader. + /// + /// This is currently [`RequirementStatus::Unknown`] because Hyperlight + /// records the requirement internally but does not expose it for + /// inspection. Restore still validates the requirement before changing + /// guest state. See . + pub fn module_loader(&self) -> RequirementStatus { + self.module_loader + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ScriptMetadata { + content: Arc, + base_path: Option, +} + +impl From<&Script> for ScriptMetadata { + fn from(script: &Script) -> Self { + Self { + content: script.shared_content(), + base_path: script.base_path().map(Path::to_path_buf), + } + } +} + +impl From for Script { + fn from(metadata: ScriptMetadata) -> Self { + let script = Script::from_shared_content(metadata.content); + match metadata.base_path { + Some(path) => script.with_virtual_base(path.to_string_lossy()), + None => script, + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SnapshotMetadata { + schema_version: u32, + hyperlight_js_version: String, + kind: SnapshotKind, + handlers: BTreeMap, + modules: BTreeMap, + host_functions: HostFunctionManifest, + // Persistence-only OCI digest used to resolve `Snapshot::base` during load. + // `Snapshot::base` is authoritative in memory; save rewrites this field. + base_snapshot: Option, +} + +impl SnapshotMetadata { + pub(crate) fn new( + kind: SnapshotKind, + handlers: impl Iterator, + modules: impl Iterator, + host_functions: HostFunctionManifest, + ) -> Self { + Self { + schema_version: METADATA_SCHEMA_VERSION, + hyperlight_js_version: env!("CARGO_PKG_VERSION").to_owned(), + kind, + handlers: handlers.collect(), + modules: modules.collect(), + host_functions, + base_snapshot: None, + } + } + + pub(crate) fn kind(&self) -> SnapshotKind { + self.kind + } + + pub(crate) fn handlers(&self) -> impl Iterator + '_ { + self.handlers + .clone() + .into_iter() + .map(|(name, script)| (name, script.into())) + } + + pub(crate) fn modules(&self) -> impl Iterator + '_ { + self.modules + .clone() + .into_iter() + .map(|(name, script)| (name, script.into())) + } + + fn requirements(&self) -> SnapshotRequirements { + SnapshotRequirements { + host_functions: self.host_functions.clone(), + module_loader: RequirementStatus::Unknown, + } + } + + /// Validate that the destination sandbox provides every logical host function + /// required by this guest state. Hyperlight validates only the single typed + /// `CallHostJsFunction` bridge, so its registry cannot see these JS module + /// and function names. + pub(crate) fn validate_host_functions(&self, available: &HostFunctionManifest) -> Result<()> { + for (module, required_functions) in &self.host_functions { + let available_functions = available.get(module).ok_or_else(|| { + new_error!( + "Snapshot requires host module '{}', but it was not registered", + module + ) + })?; + for function in required_functions { + if !available_functions.contains(function) { + return Err(new_error!( + "Snapshot requires host function '{}.{}', but it was not registered", + module, + function + )); + } + } + } + Ok(()) + } + + fn validate(&self) -> Result<()> { + if self.schema_version != METADATA_SCHEMA_VERSION { + return Err(new_error!( + "Unsupported Hyperlight JavaScript snapshot metadata version {} produced by hyperlight-js {}; expected {}", + self.schema_version, + self.hyperlight_js_version, + METADATA_SCHEMA_VERSION + )); + } + match (self.kind, self.base_snapshot.is_some()) { + (SnapshotKind::JsSandbox, true) => { + return Err(new_error!( + "JSSandbox snapshot metadata must not contain a base snapshot" + )); + } + (SnapshotKind::LoadedJsSandbox, false) => { + return Err(new_error!( + "LoadedJSSandbox snapshot metadata is missing its base snapshot" + )); + } + _ => {} + } + + if self.handlers.keys().any(|name| name.is_empty()) { + return Err(new_error!( + "Snapshot metadata contains an empty handler name" + )); + } + + for qualified_name in self.modules.keys() { + let (namespace, module_name) = qualified_name.split_once(':').ok_or_else(|| { + new_error!( + "Snapshot module name '{}' must be qualified as ':'", + qualified_name + ) + })?; + validate_module_identifier(namespace, "Module namespace")?; + validate_module_identifier(module_name, "Module name")?; + validate_namespace_not_reserved(namespace)?; + } + + Ok(()) + } +} + +/// A persistent snapshot of either a `JSSandbox` or `LoadedJSSandbox`. +#[derive(Clone)] +pub struct Snapshot { + inner: Arc, + base: Option>, + metadata: Arc, +} + +impl Snapshot { + pub(crate) fn new( + inner: Arc, + base: Option>, + metadata: Arc, + ) -> Self { + Self { + inner, + base, + metadata, + } + } + + /// Return the sandbox lifecycle state captured by this snapshot. + pub fn kind(&self) -> SnapshotKind { + self.metadata.kind() + } + + /// Return the host resources required to restore this snapshot. + pub fn requirements(&self) -> SnapshotRequirements { + self.metadata.requirements() + } + + /// Save the snapshot to an OCI image layout and return its manifest digest. + pub fn save(&self, path: impl AsRef, tag: impl AsRef) -> Result { + let tag = OciTag::new(tag.as_ref())?; + let mut metadata = self.metadata.as_ref().clone(); + if let Some(base) = &self.base { + // Hyperlight does not yet support OCI snapshot bundles, so save the + // runtime-ready baseline separately and link it through metadata. + // https://github.com/hyperlight-dev/hyperlight/issues/1865 + let base_tag = base_tag(&tag)?; + let base_digest = base.save(path.as_ref(), &base_tag)?; + metadata.base_snapshot = Some(base_digest.to_string()); + } + let snapshot = self.inner.with_metadata(METADATA_NAMESPACE, &metadata)?; + + snapshot.save(path, &tag).map(|digest| digest.to_string()) + } + + /// Load and verify a snapshot from an OCI image layout. + pub fn load(path: impl AsRef, reference: impl AsRef) -> Result { + Self::load_inner(path.as_ref(), reference.as_ref(), true) + } + + /// Load a trusted snapshot without verifying its OCI blob digests. + pub fn load_unverified(path: impl AsRef, reference: impl AsRef) -> Result { + Self::load_inner(path.as_ref(), reference.as_ref(), false) + } + + fn load_inner(path: &Path, reference: &str, verified: bool) -> Result { + let reference = reference.parse::()?; + let inner = Arc::new(if verified { + HyperlightSnapshot::checked_load(path, reference)? + } else { + HyperlightSnapshot::load(path, reference)? + }); + let metadata = inner + .metadata::(METADATA_NAMESPACE)? + .ok_or_else(|| { + new_error!( + "Snapshot does not contain Hyperlight JavaScript metadata under namespace '{}'", + METADATA_NAMESPACE + ) + })?; + metadata.validate()?; + + let base = match &metadata.base_snapshot { + Some(reference) => { + let reference = reference.parse::()?; + Some(Arc::new(if verified { + HyperlightSnapshot::checked_load(path, reference)? + } else { + HyperlightSnapshot::load(path, reference)? + })) + } + None => None, + }; + + Ok(Self { + inner, + base, + metadata: Arc::new(metadata), + }) + } + + pub(crate) fn inner(&self) -> Arc { + Arc::clone(&self.inner) + } + + pub(crate) fn base(&self) -> Option> { + self.base.clone() + } + + pub(crate) fn metadata(&self) -> &SnapshotMetadata { + &self.metadata + } +} + +fn base_tag(tag: &OciTag) -> Result { + // TODO(https://github.com/hyperlight-dev/hyperlight/issues/1865): Remove + // this shorter limit when both states are saved as one OCI bundle. + if tag.as_str().len() > MAX_LOADED_SNAPSHOT_TAG_LENGTH { + return Err(new_error!( + "LoadedJSSandbox snapshot tag must be at most {} bytes to reserve space for its runtime-ready baseline tag", + MAX_LOADED_SNAPSHOT_TAG_LENGTH + )); + } + + OciTag::new(format!("{}{BASE_TAG_SUFFIX}", tag.as_str())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn metadata(kind: SnapshotKind) -> SnapshotMetadata { + SnapshotMetadata::new( + kind, + std::iter::empty(), + std::iter::empty(), + BTreeMap::new(), + ) + } + + fn script_metadata() -> ScriptMetadata { + ScriptMetadata::from(&Script::from_content("function handler() {}")) + } + + #[test] + fn metadata_rejects_unknown_fields() { + let mut value = serde_json::to_value(metadata(SnapshotKind::JsSandbox)).unwrap(); + value["unknown"] = serde_json::json!(true); + + let error = serde_json::from_value::(value).unwrap_err(); + + assert!(error.to_string().contains("unknown field `unknown`")); + } + + #[test] + fn script_metadata_rejects_unknown_fields() { + let mut value = serde_json::to_value(script_metadata()).unwrap(); + value["unknown"] = serde_json::json!(true); + + let error = serde_json::from_value::(value).unwrap_err(); + + assert!(error.to_string().contains("unknown field `unknown`")); + } + + #[test] + fn metadata_schema_error_includes_producer_version() { + let mut metadata = metadata(SnapshotKind::JsSandbox); + metadata.schema_version = METADATA_SCHEMA_VERSION + 1; + metadata.hyperlight_js_version = "9.8.7".to_owned(); + + let error = metadata.validate().unwrap_err(); + + assert!(error + .to_string() + .contains("produced by hyperlight-js 9.8.7")); + } + + #[test] + fn loaded_metadata_does_not_require_staged_definitions() { + let mut metadata = metadata(SnapshotKind::LoadedJsSandbox); + metadata.base_snapshot = Some(format!("sha256:{}", "0".repeat(64))); + + metadata.validate().unwrap(); + } + + #[test] + fn metadata_rejects_empty_handler_name() { + let mut metadata = metadata(SnapshotKind::JsSandbox); + metadata.handlers.insert(String::new(), script_metadata()); + + let error = metadata.validate().unwrap_err(); + + assert!(error.to_string().contains("empty handler name")); + } + + #[test] + fn metadata_rejects_invalid_qualified_module_names() { + for name in [ + "module", + ":module", + "user:", + "user:module:extra", + "host:module", + ] { + let mut metadata = metadata(SnapshotKind::JsSandbox); + metadata.modules.insert(name.to_owned(), script_metadata()); + + assert!( + metadata.validate().is_err(), + "accepted module name {name:?}" + ); + } + } + + #[test] + fn metadata_accepts_valid_public_api_identifiers() { + let mut metadata = metadata(SnapshotKind::JsSandbox); + metadata + .handlers + .insert("handler:name".to_owned(), script_metadata()); + metadata + .modules + .insert("user:module".to_owned(), script_metadata()); + + metadata.validate().unwrap(); + } + + #[test] + fn requirements_expose_host_functions_without_guessing_module_loader() { + let mut metadata = metadata(SnapshotKind::LoadedJsSandbox); + metadata.host_functions.insert( + "host:database".to_owned(), + vec!["execute".to_owned(), "query".to_owned()], + ); + + let requirements = metadata.requirements(); + + assert_eq!( + requirements.host_functions().get("host:database"), + Some(&vec!["execute".to_owned(), "query".to_owned()]) + ); + assert_eq!(requirements.module_loader(), RequirementStatus::Unknown); + } + + #[test] + fn script_metadata_shares_source_and_preserves_json_shape() { + let script = + Script::from_content("export const answer = 42;").with_virtual_base("/modules"); + let original_content = script.shared_content(); + let metadata = ScriptMetadata::from(&script); + + let json = serde_json::to_value(&metadata).unwrap(); + let restored_metadata: ScriptMetadata = serde_json::from_value(json.clone()).unwrap(); + let restored_script: Script = metadata.into(); + + assert!(Arc::ptr_eq( + &original_content, + &restored_script.shared_content() + )); + assert_eq!(json["content"], "export const answer = 42;"); + assert_eq!(json["base_path"], "/modules"); + assert_eq!( + restored_metadata.content.as_ref(), + "export const answer = 42;" + ); + } + + #[test] + fn base_tag_accepts_maximum_loaded_snapshot_tag_length() { + let tag = OciTag::new("a".repeat(MAX_LOADED_SNAPSHOT_TAG_LENGTH)).unwrap(); + + let base = base_tag(&tag).unwrap(); + + assert_eq!(base.as_str().len(), MAX_OCI_TAG_LENGTH); + assert_eq!(base.as_str(), format!("{}{BASE_TAG_SUFFIX}", tag.as_str())); + } + + #[test] + fn base_tag_rejects_tag_that_would_require_truncation() { + let tag = OciTag::new("a".repeat(MAX_LOADED_SNAPSHOT_TAG_LENGTH + 1)).unwrap(); + + let error = base_tag(&tag).unwrap_err(); + + assert!(error.to_string().contains("must be at most 118 bytes")); + } + + #[test] + fn distinct_loaded_snapshot_tags_have_distinct_base_tags() { + let shared_prefix = "a".repeat(MAX_LOADED_SNAPSHOT_TAG_LENGTH - 1); + let first = OciTag::new(format!("{shared_prefix}x")).unwrap(); + let second = OciTag::new(format!("{shared_prefix}y")).unwrap(); + + assert_ne!(base_tag(&first).unwrap(), base_tag(&second).unwrap()); + } +} diff --git a/src/hyperlight-js/src/script.rs b/src/hyperlight-js/src/script.rs index adce6ba0..e62a5dc7 100644 --- a/src/hyperlight-js/src/script.rs +++ b/src/hyperlight-js/src/script.rs @@ -66,6 +66,17 @@ impl Script { &self.content } + pub(crate) fn shared_content(&self) -> Arc { + Arc::clone(&self.content) + } + + pub(crate) fn from_shared_content(content: Arc) -> Self { + Self { + content, + base_path: None, + } + } + /// Get the base path for module resolution, if any pub fn base_path(&self) -> Option<&Path> { self.base_path.as_deref() diff --git a/src/hyperlight-js/tests/host_functions.rs b/src/hyperlight-js/tests/host_functions.rs index 0801a26b..c0abf3f0 100644 --- a/src/hyperlight-js/tests/host_functions.rs +++ b/src/hyperlight-js/tests/host_functions.rs @@ -15,7 +15,12 @@ limitations under the License. */ //! Test for host modules / functions. -use hyperlight_js::{SandboxBuilder, Script}; +#![allow(clippy::disallowed_macros)] + +use hyperlight_js::{ + HostFunctionFilter, HostFunctionModule, LoadedJSSandbox, RequirementStatus, SandboxBuilder, + Script, Snapshot, +}; #[test] fn can_call_host_functions() { @@ -856,3 +861,293 @@ fn register_js_deeply_nested_binary_return() { assert_eq!(res, r#"{"inner_len":2,"first_byte":171,"label":"deep"}"#); } + +#[test] +fn js_restore_rejects_missing_host_function_before_mutation() { + let mut source_proto = SandboxBuilder::new().build().unwrap(); + source_proto + .register("required", "value", |_: i32| 1) + .unwrap(); + let mut source = source_proto.load_runtime().unwrap(); + source + .add_handler( + "source", + Script::from_content( + r#" + import * as required from "required"; + function handler(event) { return { value: required.value(0) }; } + "#, + ), + ) + .unwrap(); + let snapshot = source.snapshot().unwrap(); + let requirements = snapshot.requirements(); + assert_eq!( + requirements.host_functions().get("required"), + Some(&vec!["value".to_owned()]) + ); + assert_eq!(requirements.module_loader(), RequirementStatus::Unknown); + + let mut destination = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + destination + .add_handler( + "destination", + Script::from_content("function handler(event) { return { intact: true }; }"), + ) + .unwrap(); + + let error = destination.restore(snapshot).unwrap_err(); + assert!(error.to_string().contains("required")); + + let mut loaded = destination.get_loaded_sandbox().unwrap(); + let result = loaded + .handle_event("destination", "{}".to_owned(), None) + .unwrap(); + assert_eq!(result, r#"{"intact":true}"#); +} + +#[test] +fn loaded_restore_rejects_missing_host_function_before_mutation() { + let mut source_proto = SandboxBuilder::new().build().unwrap(); + source_proto + .register("required", "value", |_: i32| 1) + .unwrap(); + let mut source = source_proto.load_runtime().unwrap(); + source + .add_handler( + "source", + Script::from_content( + r#" + import * as required from "required"; + function handler(event) { return { value: required.value(0) }; } + "#, + ), + ) + .unwrap(); + let snapshot = source.get_loaded_sandbox().unwrap().snapshot().unwrap(); + + let mut destination = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + destination + .add_handler( + "destination", + Script::from_content("function handler(event) { return { intact: true }; }"), + ) + .unwrap(); + let mut destination = destination.get_loaded_sandbox().unwrap(); + + let error = destination.restore(snapshot).unwrap_err(); + assert!(error.to_string().contains("required")); + + let result = destination + .handle_event("destination", "{}".to_owned(), None) + .unwrap(); + assert_eq!(result, r#"{"intact":true}"#); +} + +fn required_host_function_module() -> HostFunctionModule { + let mut required = HostFunctionModule::new("required"); + required.register("value", |_: i32| 1); + required +} + +fn extra_host_function_module() -> HostFunctionModule { + let mut extra = HostFunctionModule::new("extra"); + extra.register("value", |_: i32| 2); + extra +} + +fn loaded_snapshot_requiring_host_function() -> Snapshot { + let mut source = SandboxBuilder::new() + .with_host_function_modules(|| [required_host_function_module()]) + .build() + .unwrap() + .load_runtime() + .unwrap(); + source + .add_handler( + "source", + Script::from_content( + r#" + import * as required from "required"; + function handler(event) { return { value: required.value(0) }; } + "#, + ), + ) + .unwrap(); + source.get_loaded_sandbox().unwrap().snapshot().unwrap() +} + +#[test] +fn loaded_restore_keeps_captured_requirements_until_unload() { + let snapshot = loaded_snapshot_requiring_host_function(); + let mut restored = SandboxBuilder::new() + .with_host_function_modules(|| { + [ + required_host_function_module(), + extra_host_function_module(), + ] + }) + .with_host_function_filter(HostFunctionFilter::all()) + .build_from_snapshot(snapshot) + .unwrap() + .restore::() + .unwrap(); + + let loaded_snapshot = restored.snapshot().unwrap(); + assert_eq!( + loaded_snapshot.requirements().host_functions(), + &std::collections::BTreeMap::from([("required".to_owned(), vec!["value".to_owned()])]) + ); + + let mut unloaded = restored.unload().unwrap(); + let unloaded_snapshot = unloaded.snapshot().unwrap(); + assert!(unloaded_snapshot + .requirements() + .host_functions() + .contains_key("extra")); + + unloaded + .add_handler( + "extra", + Script::from_content( + r#" + import * as extra from "extra"; + function handler(event) { return { value: extra.value(0) }; } + "#, + ), + ) + .unwrap(); + let mut reloaded = unloaded.get_loaded_sandbox().unwrap(); + let result = reloaded + .handle_event("extra", "{}".to_owned(), None) + .unwrap(); + assert_eq!(result, r#"{"value":2}"#); +} + +#[test] +fn loaded_restore_filter_limits_capabilities_after_unload() { + let snapshot = loaded_snapshot_requiring_host_function(); + let restored = SandboxBuilder::new() + .with_host_function_modules(|| { + [ + required_host_function_module(), + extra_host_function_module(), + ] + }) + .with_host_function_filter(HostFunctionFilter::snapshot_requirements()) + .build_from_snapshot(snapshot) + .unwrap() + .restore::() + .unwrap(); + + let mut unloaded = restored.unload().unwrap(); + let unloaded_snapshot = unloaded.snapshot().unwrap(); + assert_eq!( + unloaded_snapshot.requirements().host_functions(), + &std::collections::BTreeMap::from([("required".to_owned(), vec!["value".to_owned()])]) + ); +} + +#[test] +fn snapshot_filter_preserves_required_capability_contract() { + let source_proto = SandboxBuilder::new() + .with_host_function_modules(|| [required_host_function_module()]) + .build() + .unwrap(); + let mut source = source_proto.load_runtime().unwrap(); + source + .add_handler( + "source", + Script::from_content( + r#" + import * as required from "required"; + function handler(event) { return { value: required.value(0) }; } + "#, + ), + ) + .unwrap(); + let snapshot = source.snapshot().unwrap(); + + let mut restored = SandboxBuilder::new() + .with_host_function_modules(|| { + [ + required_host_function_module(), + extra_host_function_module(), + ] + }) + .with_host_function_filter(HostFunctionFilter::snapshot_requirements()) + .build_from_snapshot(snapshot) + .unwrap() + .restore::() + .unwrap(); + + let filtered_snapshot = restored.snapshot().unwrap(); + assert_eq!( + filtered_snapshot.requirements().host_functions(), + &std::collections::BTreeMap::from([("required".to_owned(), vec!["value".to_owned()])]) + ); + + let mut compatible = SandboxBuilder::new() + .with_host_function_modules(|| [required_host_function_module()]) + .build() + .unwrap() + .load_runtime() + .unwrap(); + compatible.restore(filtered_snapshot).unwrap(); +} + +#[test] +fn all_filter_records_the_complete_capability_contract() { + let source_proto = SandboxBuilder::new() + .with_host_function_modules(|| [required_host_function_module()]) + .build() + .unwrap(); + let mut source = source_proto.load_runtime().unwrap(); + source + .add_handler( + "source", + Script::from_content( + r#" + import * as required from "required"; + function handler(event) { return { value: required.value(0) }; } + "#, + ), + ) + .unwrap(); + let snapshot = source.snapshot().unwrap(); + + let mut restored = SandboxBuilder::new() + .with_host_function_modules(|| { + [ + required_host_function_module(), + extra_host_function_module(), + ] + }) + .with_host_function_filter(HostFunctionFilter::all()) + .build_from_snapshot(snapshot) + .unwrap() + .restore::() + .unwrap(); + let complete_snapshot = restored.snapshot().unwrap(); + assert!(complete_snapshot + .requirements() + .host_functions() + .contains_key("extra")); + + let mut insufficient = SandboxBuilder::new() + .with_host_function_modules(|| [required_host_function_module()]) + .build() + .unwrap() + .load_runtime() + .unwrap(); + let error = insufficient.restore(complete_snapshot).unwrap_err(); + assert!(error.to_string().contains("extra")); +} diff --git a/src/hyperlight-js/tests/module_loader.rs b/src/hyperlight-js/tests/module_loader.rs index 99f9e945..d8bb9312 100644 --- a/src/hyperlight-js/tests/module_loader.rs +++ b/src/hyperlight-js/tests/module_loader.rs @@ -15,7 +15,67 @@ limitations under the License. */ //! Tests for the module loader that import files from the embedded filesystem. -use hyperlight_js::{embed_modules, SandboxBuilder, Script}; +#![allow(clippy::disallowed_macros)] + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use hyperlight_js::{ + embed_modules, FileMetadata, FileSystem, FileSystemEmbedded, ResolveError, SandboxBuilder, + Script, Snapshot, +}; + +struct CloneCountingFileSystem { + inner: FileSystemEmbedded, + clone_count: Arc, +} + +impl CloneCountingFileSystem { + fn new(inner: FileSystemEmbedded, clone_count: Arc) -> Self { + Self { inner, clone_count } + } +} + +impl Clone for CloneCountingFileSystem { + fn clone(&self) -> Self { + self.clone_count.fetch_add(1, Ordering::Relaxed); + Self { + inner: self.inner, + clone_count: Arc::clone(&self.clone_count), + } + } +} + +impl FileSystem for CloneCountingFileSystem { + fn new() -> Self { + unreachable!("CloneCountingFileSystem must wrap an embedded file system") + } + + fn read(&self, path: &Path) -> std::io::Result> { + self.inner.read(path) + } + + fn read_to_string(&self, path: &Path) -> std::io::Result { + self.inner.read_to_string(path) + } + + fn metadata(&self, path: &Path) -> std::io::Result { + self.inner.metadata(path) + } + + fn symlink_metadata(&self, path: &Path) -> std::io::Result { + self.inner.symlink_metadata(path) + } + + fn read_link(&self, path: &Path) -> Result { + self.inner.read_link(path) + } + + fn canonicalize(&self, path: &Path) -> std::io::Result { + self.inner.canonicalize(path) + } +} #[test] fn test_handler_with_multiple_imports() { @@ -39,8 +99,10 @@ fn test_handler_with_multiple_imports() { let event = r#"{"a": 5, "b": 3}"#; - let proto_js_sandbox = SandboxBuilder::new().build().unwrap(); - let proto_js_sandbox = proto_js_sandbox.set_module_loader(fs).unwrap(); + let proto_js_sandbox = SandboxBuilder::new() + .with_module_loader(fs) + .build() + .unwrap(); let mut sandbox = proto_js_sandbox.load_runtime().unwrap(); let handler = Script::from_content(handler_content).with_virtual_base("/"); @@ -76,8 +138,12 @@ fn test_handler_import_restrictions() { } "#; - let proto_js_sandbox = SandboxBuilder::new().build().unwrap(); - let proto_js_sandbox = proto_js_sandbox.set_module_loader(fs).unwrap(); + // Compatibility coverage for callers that still configure a fresh ProtoJSSandbox. + let proto_js_sandbox = SandboxBuilder::new() + .build() + .unwrap() + .set_module_loader(fs) + .unwrap(); let mut sandbox = proto_js_sandbox.load_runtime().unwrap(); let handler = Script::from_content(handler_content).with_virtual_base("/"); @@ -131,8 +197,10 @@ fn test_handler_import_from_a_subfolder() { } "#; - let proto_js_sandbox = SandboxBuilder::new().build().unwrap(); - let proto_js_sandbox = proto_js_sandbox.set_module_loader(fs).unwrap(); + let proto_js_sandbox = SandboxBuilder::new() + .with_module_loader(fs) + .build() + .unwrap(); let mut sandbox = proto_js_sandbox.load_runtime().unwrap(); let handler = Script::from_content(handler_content).with_virtual_base("/"); @@ -146,3 +214,114 @@ fn test_handler_import_from_a_subfolder() { assert_eq!(res, "42"); } + +#[test] +fn restored_sandbox_reuses_module_resolver() { + let modules = embed_modules! { + "math.js" => "fixtures/math.js", + "strings.js" => "fixtures/strings.js", + }; + let clone_count = Arc::new(AtomicUsize::new(0)); + let file_system = CloneCountingFileSystem::new(modules, Arc::clone(&clone_count)); + let handler = Script::from_content( + r#" + import { add } from './math.js'; + import { toUpperCase } from './strings.js'; + + function handler(event) { + event.sum = add(event.a, event.b); + event.message = toUpperCase('restored'); + return event; + } + "#, + ) + .with_virtual_base("/"); + + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox.add_handler("calculator", handler).unwrap(); + let snapshot = sandbox.snapshot().unwrap(); + + let restorer = SandboxBuilder::new() + .with_module_loader(file_system) + .build_from_snapshot(snapshot) + .unwrap(); + let clones_after_setup = clone_count.load(Ordering::Relaxed); + let sandbox = restorer.restore::().unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + + assert_eq!(clone_count.load(Ordering::Relaxed), clones_after_setup); + + let result = loaded + .handle_event("calculator", r#"{"a":5,"b":3}"#.to_owned(), None) + .unwrap(); + assert!(result.contains(r#""sum":8"#)); + assert!(result.contains(r#""message":"RESTORED""#)); +} + +#[test] +fn restore_reports_required_module_loader() { + let modules = embed_modules! { + "math.js" => "fixtures/math.js", + }; + let proto = SandboxBuilder::new() + .with_module_loader(modules) + .build() + .unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + let snapshot = sandbox.snapshot().unwrap(); + let restorer = SandboxBuilder::new().build_from_snapshot(snapshot).unwrap(); + + let error = match restorer.restore::() { + Ok(_) => panic!("restore unexpectedly succeeded without a module loader"), + Err(error) => error, + }; + let message = error.to_string(); + + assert!(message.contains("Snapshot requires a module loader")); + assert!(message.contains("with_module_loader()")); + assert!(message.contains("LoadModule")); + assert!(message.contains("ResolveModule")); +} + +#[test] +fn persisted_snapshot_restores_with_a_replacement_module_loader() { + let modules = embed_modules! { + "math.js" => "fixtures/math.js", + }; + let handler = Script::from_content( + r#" + import { add } from './math.js'; + function handler(event) { return { value: add(event.value, 1) }; } + "#, + ) + .with_virtual_base("/"); + let proto = SandboxBuilder::new() + .with_module_loader(modules) + .build() + .unwrap(); + let mut sandbox = proto.load_runtime().unwrap(); + sandbox.add_handler("increment", handler).unwrap(); + let snapshot = sandbox.snapshot().unwrap(); + let directory = tempfile::tempdir().unwrap(); + snapshot.save(directory.path(), "external-modules").unwrap(); + + let snapshot = Snapshot::load(directory.path(), "external-modules").unwrap(); + let restorer = SandboxBuilder::new() + .with_module_loader(modules) + .build_from_snapshot(snapshot) + .unwrap(); + let mut loaded = restorer + .restore::() + .unwrap() + .get_loaded_sandbox() + .unwrap(); + + let result = loaded + .handle_event("increment", r#"{"value":41}"#.to_owned(), None) + .unwrap(); + assert_eq!(result, r#"{"value":42}"#); +} diff --git a/src/hyperlight-js/tests/native_modules.rs b/src/hyperlight-js/tests/native_modules.rs index 958999cb..c05b9268 100644 --- a/src/hyperlight-js/tests/native_modules.rs +++ b/src/hyperlight-js/tests/native_modules.rs @@ -28,7 +28,7 @@ limitations under the License. //! //! This recipe builds the custom runtime automatically during the host build. -use hyperlight_js::{SandboxBuilder, Script}; +use hyperlight_js::{SandboxBuilder, Script, Snapshot}; /// Test that a custom native module ("math") can be imported and used /// from a handler running inside the Hyperlight VM. @@ -132,6 +132,66 @@ fn console_log_works_with_custom_native_module() { assert_eq!(result, "54"); } +/// Test that a persisted, disk-backed snapshot of a `LoadedJSSandbox` using a +/// custom native module ("math") restores correctly and keeps the custom +/// module callable afterwards. +/// +/// Custom native modules are compiled directly into the guest binary, unlike +/// host functions, which execute in the host process. Persistent restoration +/// builds directly from the Hyperlight snapshot, so the captured guest runtime +/// already contains the native module and requires no capability reattachment. +#[test] +#[ignore] +fn persistent_snapshot_restores_custom_native_module() { + let handler = Script::from_content( + r#" + import { add, multiply } from "math"; + let calls = 0; + function handler(event) { + calls += 1; + return { + sum: add(event.a, event.b), + product: multiply(event.a, event.b), + calls, + }; + } + "#, + ); + + let directory = tempfile::tempdir().unwrap(); + sandbox.add_handler("compute", handler).unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + + // Exercise the custom module once before snapshotting so the restored + // state's `calls` counter proves guest state (not just the module) came + // back correctly. + let result = loaded + .handle_event("compute", r#"{"a":6,"b":7}"#.to_string(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["sum"], 13.0); + assert_eq!(result["product"], 42.0); + assert_eq!(result["calls"], 1); + + let snapshot = loaded.snapshot().unwrap(); + snapshot + .save(directory.path(), "custom-module-ready") + .unwrap(); + + let snapshot = Snapshot::load(directory.path(), "custom-module-ready").unwrap(); + let restorer = SandboxBuilder::new().build_from_snapshot(snapshot).unwrap(); + let mut restored = restorer + .restore::() + .unwrap(); + + let result = restored + .handle_event("compute", r#"{"a":10,"b":32}"#.to_string(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["sum"], 42.0); + assert_eq!(result["product"], 320.0); + assert_eq!(result["calls"], 2); +} #[test] #[ignore] fn custom_globals_and_host_clock_work_in_vm() { @@ -165,4 +225,4 @@ fn custom_globals_and_host_clock_work_in_vm() { assert_eq!(parsed["custom"], 42); let now = parsed["now"].as_u64().unwrap() as u128; assert!((before..=after).contains(&now)); -} +} \ No newline at end of file diff --git a/src/hyperlight-js/tests/persistent_snapshots.rs b/src/hyperlight-js/tests/persistent_snapshots.rs new file mode 100644 index 00000000..b02cca3e --- /dev/null +++ b/src/hyperlight-js/tests/persistent_snapshots.rs @@ -0,0 +1,293 @@ +/* +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. +*/ + +#![allow(clippy::disallowed_macros)] + +use hyperlight_js::{JSSandbox, LoadedJSSandbox, SandboxBuilder, Script, Snapshot, SnapshotKind}; + +fn counter_handler() -> Script { + Script::from_content( + r#" + let count = 0; + function handler(event) { + count += 1; + event.count = count; + return event; + } + "#, + ) +} + +#[test] +fn persists_and_restores_js_sandbox() { + let directory = tempfile::tempdir().unwrap(); + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox.add_handler("counter", counter_handler()).unwrap(); + + let snapshot = sandbox.snapshot().unwrap(); + assert_eq!(snapshot.kind(), SnapshotKind::JsSandbox); + let digest = snapshot.save(directory.path(), "runtime-ready").unwrap(); + assert!(digest.starts_with("sha256:")); + + let snapshot = Snapshot::load(directory.path(), "runtime-ready").unwrap(); + let restorer = SandboxBuilder::new().build_from_snapshot(snapshot).unwrap(); + let sandbox = restorer.restore::().unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + + let result = loaded + .handle_event("counter", "{}".to_owned(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["count"], 1); +} + +#[test] +fn js_sandbox_snapshot_accepts_maximum_oci_tag_length() { + let directory = tempfile::tempdir().unwrap(); + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + let snapshot = sandbox.snapshot().unwrap(); + let tag = "a".repeat(128); + + snapshot.save(directory.path(), &tag).unwrap(); + + let snapshot = Snapshot::load(directory.path(), &tag).unwrap(); + assert_eq!(snapshot.kind(), SnapshotKind::JsSandbox); +} + +#[test] +fn restores_pending_js_sandbox_state_in_memory() { + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox.add_handler("counter", counter_handler()).unwrap(); + let snapshot = sandbox.snapshot().unwrap(); + + sandbox.clear_handlers(); + sandbox + .add_handler( + "replacement", + Script::from_content("function handler(event) { return event; }"), + ) + .unwrap(); + sandbox.restore(snapshot).unwrap(); + + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + let result = loaded + .handle_event("counter", "{}".to_owned(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["count"], 1); + assert!(loaded + .handle_event("replacement", "{}".to_owned(), None) + .is_err()); +} + +#[test] +fn rejects_wrong_persisted_snapshot_lifecycle_restore() { + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox.add_handler("counter", counter_handler()).unwrap(); + let js_snapshot = sandbox.snapshot().unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + let loaded_snapshot = loaded.snapshot().unwrap(); + + let restorer = SandboxBuilder::new() + .build_from_snapshot(js_snapshot) + .unwrap(); + let error = match restorer.restore::() { + Ok(_) => panic!("restoring a JSSandbox snapshot as loaded should fail"), + Err(error) => error, + }; + assert!(error + .to_string() + .contains("Cannot restore a JsSandbox snapshot as a LoadedJSSandbox")); + + let restorer = SandboxBuilder::new() + .build_from_snapshot(loaded_snapshot) + .unwrap(); + let error = match restorer.restore::() { + Ok(_) => panic!("restoring a LoadedJSSandbox snapshot as runtime-ready should fail"), + Err(error) => error, + }; + assert!(error + .to_string() + .contains("Cannot restore a LoadedJsSandbox snapshot as a JSSandbox")); +} + +#[test] +fn persists_loaded_state_and_preserves_unload() { + let directory = tempfile::tempdir().unwrap(); + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox.add_handler("counter", counter_handler()).unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + + let result = loaded + .handle_event("counter", "{}".to_owned(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["count"], 1); + + let snapshot = loaded.snapshot().unwrap(); + assert_eq!(snapshot.kind(), SnapshotKind::LoadedJsSandbox); + snapshot + .save(directory.path(), "application-ready") + .unwrap(); + + let snapshot = Snapshot::load(directory.path(), "application-ready").unwrap(); + let restorer = SandboxBuilder::new().build_from_snapshot(snapshot).unwrap(); + let mut loaded = restorer.restore::().unwrap(); + + let result = loaded + .handle_event("counter", "{}".to_owned(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["count"], 2); + + let mut sandbox = loaded.unload().unwrap(); + sandbox.add_handler("counter", counter_handler()).unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + let result = loaded + .handle_event("counter", "{}".to_owned(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["count"], 1); +} + +#[test] +fn loaded_snapshot_can_be_resaved_to_another_layout() { + let first_directory = tempfile::tempdir().unwrap(); + let second_directory = tempfile::tempdir().unwrap(); + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox.add_handler("counter", counter_handler()).unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + + let result = loaded + .handle_event("counter", "{}".to_owned(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["count"], 1); + + let snapshot = loaded.snapshot().unwrap(); + let first_digest = snapshot + .save(first_directory.path(), "application-ready") + .unwrap(); + let snapshot = Snapshot::load(first_directory.path(), &first_digest).unwrap(); + let second_digest = snapshot + .save(second_directory.path(), "application-copy") + .unwrap(); + + assert_eq!(second_digest, first_digest); + + let snapshot = Snapshot::load(second_directory.path(), &second_digest).unwrap(); + let restorer = SandboxBuilder::new().build_from_snapshot(snapshot).unwrap(); + let mut loaded = restorer.restore::().unwrap(); + let result = loaded + .handle_event("counter", "{}".to_owned(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["count"], 2); + + let mut sandbox = loaded.unload().unwrap(); + sandbox.add_handler("counter", counter_handler()).unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + let result = loaded + .handle_event("counter", "{}".to_owned(), None) + .unwrap(); + let result: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(result["count"], 1); +} + +#[test] +fn loaded_snapshot_rejects_tag_that_would_collide_after_truncation() { + let directory = tempfile::tempdir().unwrap(); + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox.add_handler("counter", counter_handler()).unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + let snapshot = loaded.snapshot().unwrap(); + let tag = "a".repeat(119); + + let error = snapshot.save(directory.path(), tag).unwrap_err(); + + assert!(error.to_string().contains("must be at most 118 bytes")); + assert!(!directory.path().join("index.json").exists()); +} + +#[test] +fn persisted_js_sandbox_restores_embedded_javascript_modules() { + let directory = tempfile::tempdir().unwrap(); + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox + .add_module( + "math", + Script::from_content("export function double(value) { return value * 2; }"), + ) + .unwrap(); + sandbox + .add_handler( + "double", + Script::from_content( + r#" + import { double } from "user:math"; + function handler(event) { return { value: double(event.value) }; } + "#, + ), + ) + .unwrap(); + + let snapshot = sandbox.snapshot().unwrap(); + snapshot.save(directory.path(), "embedded-js").unwrap(); + let snapshot = Snapshot::load(directory.path(), "embedded-js").unwrap(); + let restorer = SandboxBuilder::new().build_from_snapshot(snapshot).unwrap(); + let mut loaded = restorer + .restore::() + .unwrap() + .get_loaded_sandbox() + .unwrap(); + + let result = loaded + .handle_event("double", r#"{"value":21}"#.to_owned(), None) + .unwrap(); + assert_eq!(result, r#"{"value":42}"#); +} diff --git a/src/js-host-api/examples/host-functions.js b/src/js-host-api/examples/host-functions.js index 3cff6610..03136022 100644 --- a/src/js-host-api/examples/host-functions.js +++ b/src/js-host-api/examples/host-functions.js @@ -4,28 +4,15 @@ // - Sync host functions (immediate return) // - Async host functions (Promise-returning) // - Multiple modules and functions -// - HostModule API and convenience register() API +// - Reusable host-function module definitions with per-sandbox state -const { SandboxBuilder } = require('../lib.js'); +const { HostFunctionModule, SandboxBuilder } = require('../lib.js'); -async function main() { - console.log('=== Hyperlight JS Host Functions ===\n'); - - // ── Build the proto sandbox ────────────────────────────────────── - console.log('1. Creating sandbox...'); - const proto = await new SandboxBuilder() - .setHeapSize(8 * 1024 * 1024) - .setScratchSize(1024 * 1024) - .build(); - console.log(' ✓ Proto sandbox created\n'); - - // ── Register host functions (sync, spread args) ────────────────── +function hostFunctionModules() { + // ── Define fresh host-function modules ─────────────────────────── // Guest JS imports these as: import * as math from "host:math" // Args are auto-parsed from JSON and spread; return value auto-stringified. - console.log('2. Registering host functions...'); - - const math = proto.hostModule('math'); - + const math = new HostFunctionModule('math'); math.register('add', (a, b) => a + b); math.register('multiply', (a, b) => a * b); @@ -33,17 +20,32 @@ async function main() { // Async callbacks are automatically awaited by the bridge. // The guest call still blocks (Hyperlight is sync), but the host // can do async work (DB queries, HTTP, file I/O, etc.) - proto.hostModule('greetings').register('hello', async (name) => { + const greetings = new HostFunctionModule('greetings'); + greetings.register('hello', async (name) => { // Simulate async work (e.g. looking up a name in a database) await new Promise((resolve) => setTimeout(resolve, 50)); return `Hello, ${name}! 👋`; }); - // ── Convenience API: register(module, name, callback) ──────────── - proto.register('strings', 'upper', (s) => s.toUpperCase()); + const strings = new HostFunctionModule('strings'); + strings.register('upper', (s) => s.toUpperCase()); + return [math, greetings, strings]; +} + +async function main() { + console.log('=== Hyperlight JS Host Functions ===\n'); + console.log('2. Registering host functions...'); console.log(' ✓ Host functions registered\n'); + // Reuse the factory for another builder; each invocation creates fresh + // module objects, callbacks, and captured state. + const proto = await new SandboxBuilder() + .setHeapSize(8 * 1024 * 1024) + .setScratchSize(1024 * 1024) + .setHostFunctionModules(hostFunctionModules) + .build(); + // ── Load runtime and add a handler ─────────────────────────────── console.log('3. Loading runtime...'); const sandbox = await proto.loadRuntime(); diff --git a/src/js-host-api/examples/persistent-snapshot.js b/src/js-host-api/examples/persistent-snapshot.js new file mode 100644 index 00000000..baa54e1b --- /dev/null +++ b/src/js-host-api/examples/persistent-snapshot.js @@ -0,0 +1,74 @@ +/* +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. +*/ + +const { HostFunctionModule, SandboxBuilder, Snapshot } = require('../lib.js'); + +const command = process.argv[2] ?? 'write'; +const path = process.argv[3] ?? '../../target/persistent-node-snapshot'; + +function hostFunctionModules() { + const counter = new HostFunctionModule('counter'); + counter.register('step', (value) => value + 1); + return [counter]; +} + +async function writeSnapshot() { + const proto = await new SandboxBuilder().setHostFunctionModules(hostFunctionModules).build(); + const sandbox = await proto.loadRuntime(); + sandbox.addHandler( + 'counter', + ` + import * as counter from "host:counter"; + let count = 0; + function handler(event) { + count = counter.step(count); + event.count = count; + return event; + } + ` + ); + const loaded = await sandbox.getLoadedSandbox(); + console.log('Before snapshot:', await loaded.callHandler('counter', {})); + + const snapshot = await loaded.snapshot(); + const digest = await snapshot.save(path, 'application-ready'); + console.log(`Saved application-ready as ${digest}`); + await loaded.dispose(); +} + +async function readSnapshot() { + const snapshot = await Snapshot.load(path, 'application-ready'); + const restorer = await new SandboxBuilder() + .setHostFunctionModules(hostFunctionModules) + .setHostFunctionFilter('snapshotRequirements') + .buildFromSnapshot(snapshot); + const loaded = await restorer.restoreLoadedSandbox(); + console.log('After restore:', await loaded.callHandler('counter', {})); + await loaded.dispose(); +} + +if (command === 'write') { + writeSnapshot().catch(fail); +} else if (command === 'read') { + readSnapshot().catch(fail); +} else { + fail(new Error(`Unknown command '${command}'; expected 'write' or 'read'`)); +} + +function fail(error) { + console.error(error); + process.exitCode = 1; +} diff --git a/src/js-host-api/lib.js b/src/js-host-api/lib.js index 024abc9e..96dbe5ce 100644 --- a/src/js-host-api/lib.js +++ b/src/js-host-api/lib.js @@ -117,7 +117,16 @@ function wrapSync(fn) { // cached by require(), so prototypes are patched once per process, after // this module has been required at least once. -const { LoadedJSSandbox, JSSandbox, ProtoJSSandbox, SandboxBuilder, HostModule } = native; +const { + LoadedJSSandbox, + JSSandbox, + ProtoJSSandbox, + SandboxRestorer, + SandboxBuilder, + Snapshot, + HostFunctionModule, + HostModule, +} = native; /** * Wrap a getter so that thrown errors have enriched codes. @@ -154,7 +163,11 @@ wrapGetter(LoadedJSSandbox, 'interruptHandle'); wrapGetter(LoadedJSSandbox, 'lastCallStats'); // JSSandbox — async + sync methods + getters -JSSandbox.prototype.getLoadedSandbox = wrapAsync(JSSandbox.prototype.getLoadedSandbox); +for (const method of ['getLoadedSandbox', 'snapshot', 'restore']) { + const orig = JSSandbox.prototype[method]; + if (!orig) throw new Error(`Cannot wrap missing method: JSSandbox.${method}`); + JSSandbox.prototype[method] = wrapAsync(orig); +} for (const method of [ 'addHandler', @@ -169,10 +182,24 @@ for (const method of [ if (!orig) throw new Error(`Cannot wrap missing method: JSSandbox.${method}`); JSSandbox.prototype[method] = wrapSync(orig); } +wrapGetter(JSSandbox, 'status'); wrapGetter(JSSandbox, 'poisoned'); -// ProtoJSSandbox — async + sync methods -ProtoJSSandbox.prototype.loadRuntime = wrapAsync(ProtoJSSandbox.prototype.loadRuntime); +// ProtoJSSandbox — fresh runtime construction +for (const method of ['loadRuntime']) { + const orig = ProtoJSSandbox.prototype[method]; + if (!orig) throw new Error(`Cannot wrap missing method: ProtoJSSandbox.${method}`); + ProtoJSSandbox.prototype[method] = wrapAsync(orig); +} + +// SandboxRestorer — snapshot restoration +for (const method of ['restoreJsSandbox', 'restoreLoadedSandbox']) { + const orig = SandboxRestorer.prototype[method]; + if (!orig) throw new Error(`Cannot wrap missing method: SandboxRestorer.${method}`); + SandboxRestorer.prototype[method] = wrapAsync(orig); +} +wrapGetter(SandboxRestorer, 'kind'); +wrapGetter(SandboxRestorer, 'requirements'); // hostModule() is sync — just wrap for error enrichment ProtoJSSandbox.prototype.hostModule = wrapSync(ProtoJSSandbox.prototype.hostModule); @@ -189,11 +216,11 @@ ProtoJSSandbox.prototype.hostModule = wrapSync(ProtoJSSandbox.prototype.hostModu }); } -// HostModule — register() with Buffer support -{ - const origRegister = HostModule.prototype.register; - if (!origRegister) throw new Error('Cannot wrap missing method: HostModule.register'); - HostModule.prototype.register = wrapSync(function (name, callback) { +// Host-function modules — register() with Buffer support +function wrapHostFunctionModule(cls) { + const origRegister = cls.prototype.register; + if (!origRegister) throw new Error(`Cannot wrap missing method: ${cls.name}.register`); + cls.prototype.register = wrapSync(function (name, callback) { // Wrap the callback to handle Buffer returns. // Args: Rust creates native Buffer objects directly via the // NAPI C API — no conversion needed on the JS side. @@ -220,21 +247,78 @@ ProtoJSSandbox.prototype.hostModule = wrapSync(ProtoJSSandbox.prototype.hostModu ); }); } +wrapHostFunctionModule(HostFunctionModule); +wrapHostFunctionModule(HostModule); + +const HostFunctionModuleExport = new Proxy(HostFunctionModule, { + construct(target, args, newTarget) { + try { + return Reflect.construct(target, args, newTarget); + } catch (err) { + throw enrichError(err); + } + }, +}); +native.HostFunctionModule = HostFunctionModuleExport; +native.HostFunctionModuleWrapper = HostFunctionModuleExport; // SandboxBuilder — async build + sync setters -SandboxBuilder.prototype.build = wrapAsync(SandboxBuilder.prototype.build); +for (const method of ['build', 'buildFromSnapshot']) { + const orig = SandboxBuilder.prototype[method]; + if (!orig) throw new Error(`Cannot wrap missing method: SandboxBuilder.${method}`); + SandboxBuilder.prototype[method] = wrapAsync(orig); +} + +// Snapshot — async persistence and static loaders +Snapshot.prototype.save = wrapAsync(Snapshot.prototype.save); +wrapGetter(Snapshot, 'kind'); + +const snapshotLoad = wrapAsync(Snapshot.load.bind(Snapshot)); +const snapshotLoadUnverified = wrapAsync(Snapshot.loadUnverified.bind(Snapshot)); +function SnapshotExport(...args) { + return Reflect.construct(Snapshot, args, Snapshot); +} +SnapshotExport.prototype = Snapshot.prototype; +Object.setPrototypeOf(SnapshotExport, Snapshot); +Object.defineProperties(SnapshotExport, { + load: { value: snapshotLoad }, + loadUnverified: { value: snapshotLoadUnverified }, +}); +native.Snapshot = SnapshotExport; for (const method of [ 'setHeapSize', 'setScratchSize', 'setInputBufferSize', 'setOutputBufferSize', + 'setHostFunctionFilter', ]) { const orig = SandboxBuilder.prototype[method]; if (!orig) throw new Error(`Cannot wrap missing method: SandboxBuilder.${method}`); SandboxBuilder.prototype[method] = wrapSync(orig); } +// A factory is required so each builder receives fresh module objects, +// callback implementations, and captured state. +{ + const origSetHostFunctionModules = SandboxBuilder.prototype.setHostFunctionModules; + if (!origSetHostFunctionModules) { + throw new Error('Cannot wrap missing method: SandboxBuilder.setHostFunctionModules'); + } + SandboxBuilder.prototype.setHostFunctionModules = wrapSync(function (factory) { + if (typeof factory !== 'function') { + throw new TypeError( + `SandboxBuilder.setHostFunctionModules expects a function, received ${typeof factory}` + ); + } + const modules = factory(); + if (!Array.isArray(modules)) { + throw new TypeError('Host-function module factory must return an array'); + } + return origSetHostFunctionModules.call(this, modules); + }); +} + // setHostPrintFn needs a custom wrapper: the user's callback is wrapped in // try/catch before it reaches the native layer, because exceptions thrown // inside a Blocking ThreadsafeFunction escape as unhandled errors. diff --git a/src/js-host-api/lib.mjs b/src/js-host-api/lib.mjs index da04dc17..f0e59a73 100644 --- a/src/js-host-api/lib.mjs +++ b/src/js-host-api/lib.mjs @@ -21,20 +21,18 @@ limitations under the License. import native from './lib.js'; export const { + __napiBindingTarget, + HostFunctionModule, HostModule, - HostModuleWrapper, InterruptHandle, - InterruptHandleWrapper, JSSandbox, - JSSandboxWrapper, LoadedJSSandbox, - LoadedJSSandboxWrapper, ProtoJSSandbox, - ProtoJSSandboxWrapper, SandboxBuilder, - SandboxBuilderWrapper, + SandboxRestorer, + SandboxStatus, Snapshot, - SnapshotWrapper, + SnapshotRequirementStatus, } = native; export default native; diff --git a/src/js-host-api/package-lock.json b/src/js-host-api/package-lock.json index 464c4561..00c31f6c 100644 --- a/src/js-host-api/package-lock.json +++ b/src/js-host-api/package-lock.json @@ -3713,9 +3713,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://github.com/7rulnik/source-map-js/archive/0a1d334fd1e55a47df97fcd60a7915d46df3b08a.tar.gz", + "integrity": "sha512-LyZ0y1bc1EaaGZYRIyWHwk64orB1T9Nrg7S05Y1oiExuGcGjWXioof+9xF7/sVCAulUyH+T743F0Zi9mNlf6TA==", "dev": true, "license": "BSD-3-Clause", "peer": true, diff --git a/src/js-host-api/package.json b/src/js-host-api/package.json index cf6134c1..f2cec851 100644 --- a/src/js-host-api/package.json +++ b/src/js-host-api/package.json @@ -50,19 +50,23 @@ "prettier": "^3.8.1", "vitest": "^5.0.0" }, + "overrides": { + "source-map-js": "https://github.com/7rulnik/source-map-js/archive/0a1d334fd1e55a47df97fcd60a7915d46df3b08a.tar.gz" + }, "engines": { "node": ">= 18" }, "scripts": { - "build": "napi build --platform --release", - "build:debug": "napi build --platform", + "build": "napi build --platform --release && npm run normalize:types", + "build:debug": "napi build --platform && npm run normalize:types", + "normalize:types": "node scripts/normalize-generated-types.mjs", "test": "vitest run", "test:watch": "vitest", - "lint": "eslint lib.js tests/ examples/", - "lint:fix": "eslint lib.js tests/ examples/ --fix", - "fmt": "prettier --write \"lib.js\" \"tests/**/*.js\" \"examples/**/*.js\" \"*.config.{js,mjs}\"", - "fmt:check": "prettier --check \"lib.js\" \"tests/**/*.js\" \"examples/**/*.js\" \"*.config.{js,mjs}\"", - "prepublishOnly": "napi prepublish -t npm --skip-optional-publish", + "lint": "eslint lib.js tests/ examples/ scripts/", + "lint:fix": "eslint lib.js tests/ examples/ scripts/ --fix", + "fmt": "prettier --write \"lib.js\" \"tests/**/*.js\" \"examples/**/*.js\" \"scripts/**/*.mjs\" \"*.config.{js,mjs}\"", + "fmt:check": "prettier --check \"lib.js\" \"tests/**/*.js\" \"examples/**/*.js\" \"scripts/**/*.mjs\" \"*.config.{js,mjs}\"", + "prepublishOnly": "napi prepublish -t npm --skip-optional-publish && npm run normalize:types", "artifacts": "napi artifacts", "version": "napi version" } diff --git a/src/js-host-api/scripts/normalize-generated-types.mjs b/src/js-host-api/scripts/normalize-generated-types.mjs new file mode 100644 index 00000000..0acb6d38 --- /dev/null +++ b/src/js-host-api/scripts/normalize-generated-types.mjs @@ -0,0 +1,40 @@ +/* +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. +*/ + +import { readFile, writeFile } from 'node:fs/promises'; + +const declarationsPath = new URL('../index.d.ts', import.meta.url); +const publicTypes = [ + 'HostFunctionModule', + 'HostModule', + 'InterruptHandle', + 'JSSandbox', + 'LoadedJSSandbox', + 'ProtoJSSandbox', + 'SandboxBuilder', + 'SandboxRestorer', + 'Snapshot', +]; + +let declarations = await readFile(declarationsPath, 'utf8'); +for (const publicType of publicTypes) { + declarations = declarations.replace( + new RegExp(`^export type ${publicType}Wrapper = ${publicType}\\r?\\n`, 'm'), + '' + ); + declarations = declarations.replaceAll(`${publicType}Wrapper`, publicType); +} +await writeFile(declarationsPath, declarations); diff --git a/src/js-host-api/src/lib.rs b/src/js-host-api/src/lib.rs index 5bce9a77..deed0e87 100644 --- a/src/js-host-api/src/lib.rs +++ b/src/js-host-api/src/lib.rs @@ -19,11 +19,13 @@ use std::time::Duration; use arc_swap::ArcSwapOption; use hyperlight_js::{ - CpuTimeMonitor, ExecutionStats, HyperlightError, InterruptHandle, JSSandbox, LoadedJSSandbox, - ProtoJSSandbox, SandboxBuilder, SandboxStatus as HyperlightSandboxStatus, Script, Snapshot, - WallClockMonitor, + CpuTimeMonitor, ExecutionStats, HostFunctionFilter, HostFunctionModule, HyperlightError, + InterruptHandle, JSSandbox, LoadedJSSandbox, ProtoJSSandbox, SandboxBuilder, SandboxRestorer, + SandboxStatus as HyperlightSandboxStatus, Script, Snapshot, WallClockMonitor, +}; +use napi::bindgen_prelude::{ + ClassInstance, FromNapiValue, JsValuesTupleIntoVec, Promise, ToNapiValue, }; -use napi::bindgen_prelude::{FromNapiValue, JsValuesTupleIntoVec, Promise, ToNapiValue}; use napi::sys::{napi_env, napi_value}; use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; use napi::{tokio, Status}; @@ -172,6 +174,8 @@ enum ErrorCode { Internal, /// Reentrant call detected — calling sandbox methods from a host callback. Reentrant, + /// Sandbox is busy with a background snapshot or restore operation. + Busy, } impl ErrorCode { @@ -185,6 +189,7 @@ impl ErrorCode { Self::Consumed => "ERR_CONSUMED", Self::Internal => "ERR_INTERNAL", Self::Reentrant => "ERR_REENTRANT", + Self::Busy => "ERR_BUSY", } } } @@ -252,7 +257,7 @@ fn consumed_error(type_name: &str) -> napi::Error { } /// Creates an error for invalid argument conditions. -fn invalid_arg_error(msg: &str) -> napi::Error { +fn invalid_arg_error(msg: impl std::fmt::Display) -> napi::Error { hl_error(ErrorCode::InvalidArg, msg) } @@ -285,14 +290,14 @@ fn validate_napi_module_identifier(value: &str, label: &str) -> napi::Result<()> // its errors through invalid_arg_error so callers consistently receive // ERR_INVALID_ARG rather than generic ERR_INTERNAL codes. if value.len() > MAX_MODULE_IDENTIFIER_LEN { - return Err(invalid_arg_error(&format!( + return Err(invalid_arg_error(format!( "{label} must not exceed {MAX_MODULE_IDENTIFIER_LEN} bytes" ))); } hyperlight_js::validate_module_identifier(value, label) - .map_err(|e| invalid_arg_error(&e.to_string()))?; + .map_err(|e| invalid_arg_error(e.to_string()))?; if value.chars().any(|c| c.is_control()) { - return Err(invalid_arg_error(&format!( + return Err(invalid_arg_error(format!( "{label} must not contain control characters" ))); } @@ -304,7 +309,7 @@ fn validate_napi_module_identifier(value: &str, label: &str) -> napi::Result<()> /// Uses the shared [`hyperlight_js::RESERVED_NAMESPACES`] list. fn validate_napi_namespace_not_reserved(namespace: &str) -> napi::Result<()> { hyperlight_js::validate_namespace_not_reserved(namespace) - .map_err(|e| invalid_arg_error(&e.to_string())) + .map_err(|e| invalid_arg_error(e.to_string())) } /// Creates an error when a Mutex is poisoned (Rust-level, not sandbox-level). @@ -332,8 +337,105 @@ fn reentrant_error() -> napi::Error { ) } +fn busy_error() -> napi::Error { + hl_error( + ErrorCode::Busy, + "JSSandbox is busy with a snapshot or restore operation; wait for it to complete", + ) +} + +struct JSSandboxBusyGuard { + flag: Arc, +} + +impl JSSandboxBusyGuard { + fn acquire(flag: Arc) -> napi::Result { + flag.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| busy_error())?; + Ok(Self { flag }) + } +} + +impl Drop for JSSandboxBusyGuard { + fn drop(&mut self) { + self.flag.store(false, Ordering::Release); + } +} + // ── Snapshot ───────────────────────────────────────────────────────── +/// Whether a host resource is required by a snapshot. +#[napi(string_enum)] +#[derive(Clone, Copy)] +pub enum SnapshotRequirementStatus { + /// The underlying snapshot API does not expose this requirement yet. + #[napi(value = "unknown")] + Unknown, + /// The resource is required to restore the snapshot. + #[napi(value = "required")] + Required, + /// The resource is not required to restore the snapshot. + #[napi(value = "notRequired")] + NotRequired, +} + +impl From for SnapshotRequirementStatus { + fn from(status: hyperlight_js::RequirementStatus) -> Self { + match status { + hyperlight_js::RequirementStatus::Unknown => Self::Unknown, + hyperlight_js::RequirementStatus::Required => Self::Required, + hyperlight_js::RequirementStatus::NotRequired => Self::NotRequired, + } + } +} + +/// Required host functions in one Hyperlight-JS host module. +#[napi(object)] +pub struct SnapshotHostModuleRequirement { + /// Qualified module name, for example `host:database`. + pub module: String, + /// Host function names required from this module. + pub functions: Vec, +} + +/// Host resources required to restore a snapshot. +#[napi(object)] +pub struct SnapshotRequirements { + /// Required Hyperlight-JS host functions grouped by module. + pub host_functions: Vec, + /// Whether a custom JavaScript module loader is required. + /// + /// This is currently `unknown` pending + /// https://github.com/hyperlight-dev/hyperlight/issues/1870. + pub module_loader: SnapshotRequirementStatus, +} + +fn snapshot_kind_name(kind: hyperlight_js::SnapshotKind) -> String { + match kind { + hyperlight_js::SnapshotKind::JsSandbox => "jsSandbox", + hyperlight_js::SnapshotKind::LoadedJsSandbox => "loadedJsSandbox", + } + .to_owned() +} + +fn snapshot_requirements( + requirements: hyperlight_js::SnapshotRequirements, +) -> SnapshotRequirements { + let host_functions = requirements + .host_functions() + .iter() + .map(|(module, functions)| SnapshotHostModuleRequirement { + module: module.clone(), + functions: functions.clone(), + }) + .collect(); + + SnapshotRequirements { + host_functions, + module_loader: requirements.module_loader().into(), + } +} + /// A captured point-in-time state of a sandbox. /// /// Take a snapshot before risky operations (e.g., running untrusted code @@ -350,7 +452,53 @@ fn reentrant_error() -> napi::Error { /// ``` #[napi(js_name = "Snapshot")] pub struct SnapshotWrapper { - inner: Arc, + inner: Snapshot, +} + +#[napi] +impl SnapshotWrapper { + /// The lifecycle state captured by this snapshot. + #[napi(getter)] + pub fn kind(&self) -> String { + snapshot_kind_name(self.inner.kind()) + } + + /// Host resources required to restore this snapshot. + #[napi(getter)] + pub fn requirements(&self) -> SnapshotRequirements { + snapshot_requirements(self.inner.requirements()) + } + + /// Save this snapshot to an OCI image layout on disk. + #[napi] + pub async fn save(&self, path: String, tag: String) -> napi::Result { + let snapshot = self.inner.clone(); + tokio::task::spawn_blocking(move || snapshot.save(path, tag).map_err(to_napi_error)) + .await + .map_err(join_error)? + } + + /// Load and verify a snapshot from an OCI image layout. + #[napi(factory)] + pub async fn load(path: String, reference: String) -> napi::Result { + let snapshot = tokio::task::spawn_blocking(move || { + Snapshot::load(path, reference).map_err(to_napi_error) + }) + .await + .map_err(join_error)??; + Ok(Self { inner: snapshot }) + } + + /// Load a trusted snapshot without verifying its OCI blob digests. + #[napi(factory)] + pub async fn load_unverified(path: String, reference: String) -> napi::Result { + let snapshot = tokio::task::spawn_blocking(move || { + Snapshot::load_unverified(path, reference).map_err(to_napi_error) + }) + .await + .map_err(join_error)??; + Ok(Self { inner: snapshot }) + } } // ── SandboxBuilder ─────────────────────────────────────────────────── @@ -482,6 +630,76 @@ impl SandboxBuilderWrapper { self.with_inner(|b| b.with_guest_heap_size(size as u64)) } + /// Supply host-function modules for this sandbox. + /// + /// Each module is consumed and cannot be supplied to another builder. + /// Use a constructor function to create equivalent modules with fresh + /// callback implementations for another sandbox. + #[napi] + pub fn set_host_function_modules( + &self, + #[napi(ts_arg_type = "() => Array")] factory: Vec< + ClassInstance<'_, HostFunctionModuleWrapper>, + >, + ) -> napi::Result<&Self> { + // lib.js invokes the public factory before crossing the native boundary. + let mut builder_guard = self.inner.lock().map_err(|_| lock_error())?; + if builder_guard.is_none() { + return Err(consumed_error("SandboxBuilder")); + } + + for (index, module) in factory.iter().enumerate() { + if factory[..index] + .iter() + .any(|candidate| std::ptr::eq(&**candidate, &**module)) + { + return Err(invalid_arg_error( + "Host-function module factory returned the same module object more than once", + )); + } + } + + let mut module_guards = factory + .iter() + .map(|module| module.inner.lock().map_err(|_| lock_error())) + .collect::>>()?; + if module_guards.iter().any(|module| module.is_none()) { + return Err(consumed_error("HostFunctionModule")); + } + + let modules = module_guards + .iter_mut() + .map(|module| { + module + .take() + .ok_or_else(|| consumed_error("HostFunctionModule")) + }) + .collect::>>()?; + let builder = builder_guard + .take() + .ok_or_else(|| consumed_error("SandboxBuilder"))?; + *builder_guard = Some(builder.with_host_function_modules(move || modules)); + Ok(self) + } + + /// Select which supplied host functions are installed. + #[napi] + pub fn set_host_function_filter( + &self, + #[napi(ts_arg_type = "'all' | 'snapshotRequirements'")] filter: String, + ) -> napi::Result<&Self> { + let filter = match filter.as_str() { + "all" => HostFunctionFilter::All, + "snapshotRequirements" => HostFunctionFilter::SnapshotRequirements, + value => { + return Err(invalid_arg_error(format!( + "Unknown host-function filter '{value}'" + ))) + } + }; + self.with_inner(|builder| builder.with_host_function_filter(filter)) + } + /// Build a `ProtoJSSandbox` from this builder's configuration. /// /// This allocates the sandbox VM resources. The builder is consumed @@ -504,6 +722,24 @@ impl SandboxBuilderWrapper { }) } + /// Build a restorer configured for the supplied snapshot. + #[napi] + pub async fn build_from_snapshot( + &self, + snapshot: &SnapshotWrapper, + ) -> napi::Result { + let builder = self.take_inner()?; + let snapshot = snapshot.inner.clone(); + let restorer = tokio::task::spawn_blocking(move || { + builder.build_from_snapshot(snapshot).map_err(to_napi_error) + }) + .await + .map_err(join_error)??; + Ok(SandboxRestorerWrapper { + inner: Arc::new(Mutex::new(Some(restorer))), + }) + } + /// Set a callback that receives guest `console.log` / `print` output. /// /// Without this, guest print output is silently discarded. The callback @@ -624,6 +860,7 @@ impl ProtoJSSandboxWrapper { .map_err(join_error)??; Ok(JSSandboxWrapper { inner: Arc::new(Mutex::new(Some(js_sandbox))), + busy_flag: Arc::new(AtomicBool::new(false)), }) } @@ -689,6 +926,89 @@ impl ProtoJSSandboxWrapper { } } +// ── SandboxRestorer ────────────────────────────────────────────────── + +/// Restores a snapshot using host resources configured on `SandboxBuilder`. +#[napi(js_name = "SandboxRestorer")] +#[derive(Clone)] +pub struct SandboxRestorerWrapper { + inner: Arc>>, +} + +impl SandboxRestorerWrapper { + fn with_inner_mut(&self, f: F) -> napi::Result + where + F: FnOnce(&mut SandboxRestorer) -> napi::Result, + { + let mut guard = self.inner.lock().map_err(|_| lock_error())?; + let restorer = guard + .as_mut() + .ok_or_else(|| consumed_error("SandboxRestorer"))?; + f(restorer) + } + + fn take_inner(&self) -> napi::Result { + self.inner + .lock() + .map_err(|_| lock_error())? + .take() + .ok_or_else(|| consumed_error("SandboxRestorer")) + } +} + +#[napi] +impl SandboxRestorerWrapper { + /// The lifecycle state captured by the snapshot. + #[napi(getter)] + pub fn kind(&self) -> napi::Result { + self.with_inner_mut(|restorer| Ok(snapshot_kind_name(restorer.kind()))) + } + + /// Host resources required to restore the snapshot. + #[napi(getter)] + pub fn requirements(&self) -> napi::Result { + self.with_inner_mut(|restorer| Ok(snapshot_requirements(restorer.requirements()))) + } + + /// Restore the snapshot as a `JSSandbox`. + #[napi] + pub async fn restore_js_sandbox(&self) -> napi::Result { + let restorer = self.take_inner()?; + let sandbox = tokio::task::spawn_blocking(move || { + restorer.restore::().map_err(to_napi_error) + }) + .await + .map_err(join_error)??; + Ok(JSSandboxWrapper { + inner: Arc::new(Mutex::new(Some(sandbox))), + busy_flag: Arc::new(AtomicBool::new(false)), + }) + } + + /// Restore the snapshot as a `LoadedJSSandbox`. + #[napi] + pub async fn restore_loaded_sandbox(&self) -> napi::Result { + let restorer = self.take_inner()?; + let loaded_sandbox = tokio::task::spawn_blocking(move || { + restorer.restore::().map_err(to_napi_error) + }) + .await + .map_err(join_error)??; + let interrupt = loaded_sandbox.interrupt_handle(); + let status = Arc::new(AtomicU8::new( + SandboxStatus::from_hyperlight(loaded_sandbox.status()).as_u8(), + )); + Ok(LoadedJSSandboxWrapper { + inner: Arc::new(AsyncMutex::new(Some(loaded_sandbox))), + interrupt, + status, + last_call_stats: Arc::new(ArcSwapOption::empty()), + disposed_flag: Arc::new(AtomicBool::new(false)), + executing_flag: Arc::new(AtomicBool::new(false)), + }) + } +} + /// Napi takes a tuple of values as the generic for `ThreadsafeFunction`'s input arguments. /// This wrapper allows us to take a variable number of arguments in a `Vec` instead of a tuple with a fixed number of elements. pub struct Rest(pub Vec); @@ -1377,12 +1697,131 @@ unsafe fn napi_to_json_with_buffer_extraction( } } -// ── HostModule ─────────────────────────────────────────────────────── +// ── Host functions ────────────────────────────────────────────────── + +type JsHostFunction = ThreadsafeFunction< + Rest>, + Promise>, + Rest>, + Status, + false, + true, +>; + +fn js_host_function( + func: JsHostFunction, +) -> impl Fn(serde_json::Value, Vec>) -> hyperlight_js::Result + + Send + + Sync + + 'static { + move |args: serde_json::Value, + blobs: Vec>| + -> hyperlight_js::Result { + use hyperlight_js::FnReturn; + use ThreadsafeFunctionCallMode::NonBlocking; + + let blobs = Arc::new(blobs); + let js_args: Vec> = match args { + JsonValue::Array(arguments) => arguments + .into_iter() + .map(|value| { + Some(JsArg { + value, + blobs: Arc::clone(&blobs), + }) + }) + .collect(), + value => vec![Some(JsArg { value, blobs })], + }; + + let (sender, receiver) = oneshot::channel(); + let status = func.call_with_return_value(Rest(js_args), NonBlocking, move |result, _| { + let _ = sender.send(result); + Ok(()) + }); + if status != Status::Ok { + return Err(HyperlightError::Error(format!( + "Host function call failed: {status:?}" + ))); + } + + tokio::runtime::Handle::current().block_on(async move { + let promise = receiver + .await + .map_err(|_| HyperlightError::Error("Channel closed".into()))? + .map_err(|error| HyperlightError::Error(format!("{error}")))?; + let value = promise + .await + .map_err(|error| HyperlightError::Error(format!("{error}")))?; + + match value { + Some(JsReturn::Buffer(bytes)) => Ok(FnReturn::Binary(bytes)), + Some(JsReturn::Value(value, blobs)) => { + let json = serde_json::to_string(&value)?; + if blobs.is_empty() { + Ok(FnReturn::Json(json)) + } else { + let sidecar = hyperlight_js_common::encode_binaries(&blobs) + .map_err(|error| HyperlightError::Error(format!("{error}")))?; + Ok(FnReturn::JsonWithBinaries(json, sidecar)) + } + } + None => Ok(FnReturn::Json("null".into())), + } + }) + } +} + +/// A named collection of host functions for one sandbox. +/// +/// Host-function modules execute callbacks in the Node.js host process. They +/// are distinct from native modules and JavaScript source modules executing in +/// the guest. Create a fresh module for each sandbox so callback state cannot +/// leak between sandboxes. +#[napi(js_name = "HostFunctionModule")] +pub struct HostFunctionModuleWrapper { + inner: Mutex>, +} + +#[napi] +impl HostFunctionModuleWrapper { + /// Create an empty host-function module for one sandbox. + #[napi(constructor)] + pub fn new(name: String) -> napi::Result { + validate_module_name(&name)?; + Ok(Self { + inner: Mutex::new(Some(HostFunctionModule::new(format!( + "{HOST_MODULE_PREFIX}{name}" + )))), + }) + } + + /// Register a host function in this reusable module. + #[napi] + pub fn register( + &self, + name: String, + #[napi(ts_arg_type = "(...args: unknown[]) => unknown | Promise")] + func: JsHostFunction, + ) -> napi::Result<&Self> { + if name.is_empty() { + return Err(invalid_arg_error("Function name must not be empty")); + } + let mut guard = self.inner.lock().map_err(|_| lock_error())?; + let module = guard + .as_mut() + .ok_or_else(|| consumed_error("HostFunctionModule"))?; + module.register_js(name, js_host_function(func)); + Ok(self) + } +} + +// ── HostModule compatibility API ──────────────────────────────────── /// A builder for registering host functions in a named module. /// -/// Obtained from `ProtoJSSandbox.hostModule(name)`. Host functions -/// registered here become available to guest JavaScript code via +/// Obtained from the compatibility API `ProtoJSSandbox.hostModule(name)`. +/// Host functions registered here become available to guest JavaScript code via /// `import * as from "host:"` after `loadRuntime()` is called. /// /// The `host:` prefix is added automatically by the NAPI layer to prevent @@ -1409,8 +1848,7 @@ pub struct HostModuleWrapper { /// Module name this builder registers functions under. module_name: String, - /// Reference to the parent `ProtoJSSandboxWrapper`'s inner sandbox, for - /// applying registrations. + /// Fresh sandbox that receives registrations from this compatibility handle. sandbox: ProtoJSSandboxWrapper, } @@ -1458,91 +1896,14 @@ impl HostModuleWrapper { pub fn register( &self, name: String, - func: ThreadsafeFunction< - Rest>, - Promise>, - Rest>, - Status, - false, - true, - >, + #[napi(ts_arg_type = "(...args: unknown[]) => unknown | Promise")] + func: JsHostFunction, ) -> napi::Result<()> { if name.is_empty() { return Err(invalid_arg_error("Function name must not be empty")); } - // Use binary-capable registration to support Buffer arguments. - // The closure receives parsed JsonValue args (with {"__bin__": N} - // placeholders) and decoded binary blobs. JsArg's ToNapiValue - // impl converts placeholders directly to native Node.js Buffers - // via the NAPI API — no base64 encoding needed. - let wrapper = move |args: serde_json::Value, - blobs: Vec>| - -> hyperlight_js::Result { - use hyperlight_js::FnReturn; - use ThreadsafeFunctionCallMode::NonBlocking; - - let blobs = Arc::new(blobs); - - // Spread the JSON array into individual JsArg values. - // Each JsArg carries a reference to the blobs so its - // ToNapiValue impl can resolve __bin__ placeholders at - // any nesting depth. - let js_args: Vec> = match args { - JsonValue::Array(arr) => arr - .into_iter() - .map(|v| { - Some(JsArg { - value: v, - blobs: blobs.clone(), - }) - }) - .collect(), - other => vec![Some(JsArg { - value: other, - blobs: blobs.clone(), - })], - }; - - let (tx, rx) = oneshot::channel(); - let status = - func.call_with_return_value(Rest(js_args), NonBlocking, move |result, _| { - let _ = tx.send(result); - Ok(()) - }); - if status != Status::Ok { - return Err(HyperlightError::Error(format!( - "Host function call failed: {status:?}" - ))); - } - tokio::runtime::Handle::current().block_on(async move { - let promise = rx - .await - .map_err(|_| HyperlightError::Error("Channel closed".into()))? - .map_err(|err| HyperlightError::Error(format!("{err}")))?; - - let value = promise - .await - .map_err(|err| HyperlightError::Error(format!("{err}")))?; - - // JsReturn extracts nested Buffers into blobs via the - // recursive NAPI walker — no base64 round-trip needed. - match value { - Some(JsReturn::Buffer(bytes)) => Ok(FnReturn::Binary(bytes)), - Some(JsReturn::Value(v, blobs)) => { - let json = serde_json::to_string(&v)?; - if blobs.is_empty() { - Ok(FnReturn::Json(json)) - } else { - let sidecar = hyperlight_js_common::encode_binaries(&blobs) - .map_err(|e| HyperlightError::Error(format!("{e}")))?; - Ok(FnReturn::JsonWithBinaries(json, sidecar)) - } - } - None => Ok(FnReturn::Json("null".into())), - } - }) - }; + let wrapper = js_host_function(func); self.sandbox.with_inner_mut(|sandbox| { sandbox .host_module(&self.module_name) @@ -1572,6 +1933,7 @@ impl HostModuleWrapper { #[napi(js_name = "JSSandbox")] pub struct JSSandboxWrapper { inner: Arc>>, + busy_flag: Arc, } impl JSSandboxWrapper { @@ -1580,6 +1942,7 @@ impl JSSandboxWrapper { where F: FnOnce(&mut JSSandbox) -> napi::Result, { + let _busy_guard = JSSandboxBusyGuard::acquire(Arc::clone(&self.busy_flag))?; let mut guard = self.inner.lock().map_err(|_| lock_error())?; let sandbox = guard.as_mut().ok_or_else(|| consumed_error("JSSandbox"))?; f(sandbox) @@ -1590,6 +1953,7 @@ impl JSSandboxWrapper { where F: FnOnce(&JSSandbox) -> napi::Result, { + let _busy_guard = JSSandboxBusyGuard::acquire(Arc::clone(&self.busy_flag))?; let guard = self.inner.lock().map_err(|_| lock_error())?; let sandbox = guard.as_ref().ok_or_else(|| consumed_error("JSSandbox"))?; f(sandbox) @@ -1597,6 +1961,7 @@ impl JSSandboxWrapper { /// Take ownership of the inner value via Mutex, or error if consumed. fn take_inner(&self) -> napi::Result { + let _busy_guard = JSSandboxBusyGuard::acquire(Arc::clone(&self.busy_flag))?; self.inner .lock() .map_err(|_| lock_error())? @@ -1776,6 +2141,38 @@ impl JSSandboxWrapper { }) } + /// Capture the runtime state and handlers and modules added to this sandbox + /// but not yet registered in the guest. + #[napi] + pub async fn snapshot(&self) -> napi::Result { + let _busy_guard = JSSandboxBusyGuard::acquire(Arc::clone(&self.busy_flag))?; + let inner = Arc::clone(&self.inner); + let snapshot = tokio::task::spawn_blocking(move || { + let mut guard = inner.lock().map_err(|_| lock_error())?; + let sandbox = guard.as_mut().ok_or_else(|| consumed_error("JSSandbox"))?; + sandbox.snapshot().map_err(to_napi_error) + }) + .await + .map_err(join_error)??; + Ok(SnapshotWrapper { inner: snapshot }) + } + + /// Restore the runtime state and handlers and modules that were added to the + /// snapshotted sandbox but not yet registered in the guest. + #[napi] + pub async fn restore(&self, snapshot: &SnapshotWrapper) -> napi::Result<()> { + let _busy_guard = JSSandboxBusyGuard::acquire(Arc::clone(&self.busy_flag))?; + let inner = Arc::clone(&self.inner); + let snapshot = snapshot.inner.clone(); + tokio::task::spawn_blocking(move || { + let mut guard = inner.lock().map_err(|_| lock_error())?; + let sandbox = guard.as_mut().ok_or_else(|| consumed_error("JSSandbox"))?; + sandbox.restore(snapshot).map_err(to_napi_error) + }) + .await + .map_err(join_error)? + } + /// The sandbox lifecycle status. #[napi(getter)] pub fn status(&self) -> napi::Result { @@ -1803,11 +2200,32 @@ impl JSSandboxWrapper { /// Calling `dispose()` on an already-consumed sandbox is a no-op. #[napi] pub fn dispose(&self) -> napi::Result<()> { + let _busy_guard = JSSandboxBusyGuard::acquire(Arc::clone(&self.busy_flag))?; let _ = self.inner.lock().map_err(|_| lock_error())?.take(); Ok(()) } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn js_sandbox_busy_guard_is_exclusive_and_resets_on_drop() { + let flag = Arc::new(AtomicBool::new(false)); + let guard = JSSandboxBusyGuard::acquire(Arc::clone(&flag)).unwrap(); + + let error = match JSSandboxBusyGuard::acquire(Arc::clone(&flag)) { + Ok(_) => panic!("a second busy guard was acquired"), + Err(error) => error, + }; + assert!(error.reason.contains("ERR_BUSY")); + + drop(guard); + assert!(JSSandboxBusyGuard::acquire(flag).is_ok()); + } +} + // ── LoadedJSSandbox ────────────────────────────────────────────────── /// An execution-ready sandbox with handlers loaded. @@ -2017,14 +2435,14 @@ impl LoadedJSSandboxWrapper { if let Some(wall_ms) = options.wall_clock_timeout_ms && !(MIN_TIMEOUT_MS..=MAX_TIMEOUT_MS).contains(&wall_ms) { - return Err(invalid_arg_error(&format!( + return Err(invalid_arg_error(format!( "wallClockTimeoutMs must be between {MIN_TIMEOUT_MS}ms and {MAX_TIMEOUT_MS}ms, got {wall_ms}" ))); } if let Some(cpu_ms) = options.cpu_timeout_ms && !(MIN_TIMEOUT_MS..=MAX_TIMEOUT_MS).contains(&cpu_ms) { - return Err(invalid_arg_error(&format!( + return Err(invalid_arg_error(format!( "cpuTimeoutMs must be between {MIN_TIMEOUT_MS}ms and {MAX_TIMEOUT_MS}ms, got {cpu_ms}" ))); } @@ -2037,7 +2455,7 @@ impl LoadedJSSandboxWrapper { // Serialize the JS object to a JSON string for the hypervisor let event_json = serde_json::to_string(&event_data) - .map_err(|e| invalid_arg_error(&format!("Failed to serialize event: {e}")))?; + .map_err(|e| invalid_arg_error(format!("Failed to serialize event: {e}")))?; let result_json = self .with_blocking_inner(move |mut sandbox| { @@ -2125,6 +2543,7 @@ impl LoadedJSSandboxWrapper { .await?; Ok(JSSandboxWrapper { inner: Arc::new(Mutex::new(Some(js_sandbox))), + busy_flag: Arc::new(AtomicBool::new(false)), }) } diff --git a/src/js-host-api/tests/persistent-snapshots.test.js b/src/js-host-api/tests/persistent-snapshots.test.js new file mode 100644 index 00000000..48ea84dd --- /dev/null +++ b/src/js-host-api/tests/persistent-snapshots.test.js @@ -0,0 +1,253 @@ +/* +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. +*/ + +import { afterEach, describe, expect, it } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { URL } from 'node:url'; + +import { HostFunctionModule, SandboxBuilder, Snapshot } from '../lib.js'; + +const directories = []; + +async function snapshotDirectory() { + const directory = await mkdtemp(join(tmpdir(), 'hyperlight-js-snapshot-')); + directories.push(directory); + return directory; +} + +function stateHostFunctionModules() { + const state = new HostFunctionModule('state'); + state.register('next', () => 1); + return [state]; +} + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })) + ); +}); + +describe('Persistent snapshots', () => { + it('exports the restoration API through ESM', async () => { + const esm = await import('../lib.mjs'); + + expect(esm.HostFunctionModule).toBe(HostFunctionModule); + expect(Object.keys(esm).some((name) => name.endsWith('Wrapper'))).toBe(false); + expect(typeof esm.SandboxRestorer).toBe('function'); + expect(esm.SandboxStatus).toBeDefined(); + expect(esm.SnapshotRequirementStatus).toBeDefined(); + }); + + it('generates self-contained TypeScript declarations', async () => { + const declarations = await readFile(new URL('../index.d.ts', import.meta.url), 'utf8'); + + expect(declarations).not.toContain('JsHostFunction'); + expect(declarations).not.toMatch(/\b\w+Wrapper\b/); + expect(declarations).toContain( + "setHostFunctionFilter(filter: 'all' | 'snapshotRequirements'): this" + ); + }); + + it('enriches reusable host-function configuration errors', async () => { + expect(() => new HostFunctionModule('')).toThrow( + expect.objectContaining({ code: 'ERR_INVALID_ARG' }) + ); + + const builder = new SandboxBuilder(); + expect(() => builder.setHostFunctionFilter('invalid')).toThrow( + expect.objectContaining({ code: 'ERR_INVALID_ARG' }) + ); + const proto = await builder.build(); + expect(() => builder.setHostFunctionModules(() => [])).toThrow( + expect.objectContaining({ code: 'ERR_CONSUMED' }) + ); + const sandbox = await proto.loadRuntime(); + sandbox.dispose(); + }); + + it('prevents sharing a host-function module across sandbox builders', () => { + const [state] = stateHostFunctionModules(); + const modules = () => [state]; + + new SandboxBuilder().setHostFunctionModules(modules); + + expect(() => new SandboxBuilder().setHostFunctionModules(modules)).toThrow( + expect.objectContaining({ code: 'ERR_CONSUMED' }) + ); + expect(() => state.register('another', () => 2)).toThrow( + expect.objectContaining({ code: 'ERR_CONSUMED' }) + ); + }); + + it('does not consume modules when a host-function batch is rejected', () => { + const available = new HostFunctionModule('available'); + available.register('value', () => 1); + const consumed = new HostFunctionModule('consumed'); + consumed.register('value', () => 2); + new SandboxBuilder().setHostFunctionModules(() => [consumed]); + + const builder = new SandboxBuilder(); + expect(() => builder.setHostFunctionModules(() => [available, consumed])).toThrow( + expect.objectContaining({ code: 'ERR_CONSUMED' }) + ); + + available.register('stillAvailable', () => true); + expect(builder.setHeapSize(8 * 1024 * 1024)).toBe(builder); + }); + + it('rejects duplicate module objects without consuming them', () => { + const duplicate = new HostFunctionModule('duplicate'); + duplicate.register('value', () => 1); + + expect(() => + new SandboxBuilder().setHostFunctionModules(() => [duplicate, duplicate]) + ).toThrow(expect.objectContaining({ code: 'ERR_INVALID_ARG' })); + + duplicate.register('stillAvailable', () => true); + }); + + it('does not consume modules when the sandbox builder is consumed', async () => { + const available = new HostFunctionModule('available'); + available.register('value', () => 1); + const builder = new SandboxBuilder(); + const proto = await builder.build(); + + expect(() => builder.setHostFunctionModules(() => [available])).toThrow( + expect.objectContaining({ code: 'ERR_CONSUMED' }) + ); + available.register('stillAvailable', () => true); + + const sandbox = await proto.loadRuntime(); + sandbox.dispose(); + }); + + it('enriches static snapshot load errors', async () => { + await expect(Snapshot.load('missing-snapshot-layout', 'missing')).rejects.toMatchObject({ + code: 'ERR_INTERNAL', + }); + }); + + it('persists and restores a JSSandbox with handlers not yet registered in the guest', async () => { + const directory = await snapshotDirectory(); + const proto = await new SandboxBuilder().build(); + const sandbox = await proto.loadRuntime(); + sandbox.addHandler( + 'counter', + ` + let count = 0; + function handler(event) { + event.count = ++count; + return event; + } + ` + ); + + const snapshot = await sandbox.snapshot(); + expect(snapshot.kind).toBe('jsSandbox'); + const digest = await snapshot.save(directory, 'runtime-ready'); + expect(digest).toMatch(/^sha256:/); + sandbox.dispose(); + + const persisted = await Snapshot.load(directory, 'runtime-ready'); + const restorer = await new SandboxBuilder().buildFromSnapshot(persisted); + expect(restorer.loadRuntime).toBeUndefined(); + expect(typeof restorer.restoreJsSandbox).toBe('function'); + const restoredSandbox = await restorer.restoreJsSandbox(); + const loaded = await restoredSandbox.getLoadedSandbox(); + + await expect(loaded.callHandler('counter', {})).resolves.toEqual({ count: 1 }); + await loaded.dispose(); + }); + + it('persists loaded guest state and restores it in a new sandbox', async () => { + const directory = await snapshotDirectory(); + const proto = await new SandboxBuilder().build(); + const sandbox = await proto.loadRuntime(); + sandbox.addHandler( + 'counter', + ` + let count = 0; + function handler(event) { + event.count = ++count; + return event; + } + ` + ); + const loaded = await sandbox.getLoadedSandbox(); + await expect(loaded.callHandler('counter', {})).resolves.toEqual({ count: 1 }); + + const snapshot = await loaded.snapshot(); + expect(snapshot.kind).toBe('loadedJsSandbox'); + await snapshot.save(directory, 'application-ready'); + await loaded.dispose(); + + const persisted = await Snapshot.load(directory, 'application-ready'); + const restorer = await new SandboxBuilder().buildFromSnapshot(persisted); + const restored = await restorer.restoreLoadedSandbox(); + + await expect(restored.callHandler('counter', {})).resolves.toEqual({ count: 2 }); + const unloaded = await restored.unload(); + unloaded.addHandler('replacement', 'function handler(event) { return event; }'); + const reloaded = await unloaded.getLoadedSandbox(); + await expect(reloaded.callHandler('replacement', { ok: true })).resolves.toEqual({ + ok: true, + }); + await reloaded.dispose(); + }); + + it('requires host functions used by the persisted sandbox', async () => { + const directory = await snapshotDirectory(); + const proto = await new SandboxBuilder() + .setHostFunctionModules(stateHostFunctionModules) + .build(); + const sandbox = await proto.loadRuntime(); + sandbox.addHandler( + 'handler', + ` + import * as state from "host:state"; + function handler(event) { + event.value = state.next(); + return event; + } + ` + ); + const loaded = await sandbox.getLoadedSandbox(); + const snapshot = await loaded.snapshot(); + await snapshot.save(directory, 'host-functions'); + await loaded.dispose(); + + const persisted = await Snapshot.load(directory, 'host-functions'); + expect(persisted.requirements).toEqual({ + hostFunctions: [{ module: 'host:state', functions: ['next'] }], + moduleLoader: 'unknown', + }); + const missingRestorer = await new SandboxBuilder().buildFromSnapshot(persisted); + await expect(missingRestorer.restoreLoadedSandbox()).rejects.toThrow( + /requires host module 'host:state'/ + ); + + const restorer = await new SandboxBuilder() + .setHostFunctionModules(stateHostFunctionModules) + .setHostFunctionFilter('snapshotRequirements') + .buildFromSnapshot(persisted); + const restored = await restorer.restoreLoadedSandbox(); + await expect(restored.callHandler('handler', {})).resolves.toEqual({ value: 1 }); + await restored.dispose(); + }); +}); diff --git a/src/js-host-api/vitest.config.js b/src/js-host-api/vitest.config.mjs similarity index 68% rename from src/js-host-api/vitest.config.js rename to src/js-host-api/vitest.config.mjs index f97140f8..428064f9 100644 --- a/src/js-host-api/vitest.config.js +++ b/src/js-host-api/vitest.config.mjs @@ -2,6 +2,8 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + // Hyperlight sandboxes use hardware virtualization and must not compete across workers. + fileParallelism: false, // Test files pattern include: ['tests/**/*.test.js'], // Increase timeout for sandbox operations (some involve busy loops) From 85ca19d714cd4656d4e55ebcdefae1c4d194d31f Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Thu, 8 Oct 2026 22:05:40 +0100 Subject: [PATCH 2/5] Document persistent snapshot workflows Explain durable and in-memory snapshot lifecycles, host-function factories, required versus available capabilities, external and native module behavior, and the current module-loader introspection limitation. Include Rust and Node restoration examples and migration guidance for the breaking snapshot API changes. Signed-off-by: Simon Davies --- README.md | 1 + docs/persistent-snapshots.md | 338 +++++++++++++++++++++++++++++ src/js-host-api/README.md | 195 +++++++++++------ src/js-host-api/examples/README.md | 29 +++ 4 files changed, 493 insertions(+), 70 deletions(-) create mode 100644 docs/persistent-snapshots.md diff --git a/README.md b/README.md index 80d9ba67..9db95226 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Provides a capability to run JavaScript inside of Hyperlight using quickjs as th - [Custom guest runtimes](docs/extending-runtime.md) - Extend with native modules and build and embed a custom guest using cargo-hyperlight - [Execution Monitors](docs/execution-monitors.md) - Timeout and resource limit enforcement for handler execution +- [Persistent snapshots](docs/persistent-snapshots.md) - Save and restore sandbox state across processes - [Observability](docs/observability.md) - Metrics and tracing - [Crashdumps](docs/create-and-analyse-guest-crashdumps.md) - Creating and analyzing guest crash dumps - [Debugging the guest runtime](docs/guest-runtime-debugging.md) - Debugging the guest runtime using GDB or LLDB diff --git a/docs/persistent-snapshots.md b/docs/persistent-snapshots.md new file mode 100644 index 00000000..827e158a --- /dev/null +++ b/docs/persistent-snapshots.md @@ -0,0 +1,338 @@ +# Persistent snapshots + +hyperlight-js sandboxes can be captured to hyperlight's OCI image-layout +snapshot format and restored after the original sandbox or process has exited. +Snapshots are immutable and may be addressed by a human-readable tag or their +`sha256:` manifest digest. + +Two lifecycle states are supported: + +- `JSSandbox` snapshots contain the initialized JavaScript runtime together with + handler and module definitions added to `JSSandbox` but not yet registered in + the guest. +- `LoadedJSSandbox` snapshots additionally contain evaluated guest handlers, + closures, and current mutable guest state, including module-scoped state. + +An OCI image layout is one directory on disk. The same layout can contain any +number of independently tagged `JSSandbox` and `LoadedJSSandbox` snapshots; +each tag or manifest digest identifies one snapshot. + +In-memory `restore()` remains the fastest way to roll back an existing sandbox. +`Snapshot::save()` and `Snapshot::load()` provide a durable, cross-process +workflow. + +## Rust + +```rust +use hyperlight_js::{LoadedJSSandbox, SandboxBuilder, Script, Snapshot}; + +fn main() -> hyperlight_js::Result<()> { + let mut sandbox = SandboxBuilder::new().build()?.load_runtime()?; + sandbox.add_handler( + "handler", + Script::from_content("function handler(event) { return event; }"), + )?; + let mut loaded = sandbox.get_loaded_sandbox()?; + + let snapshot = loaded.snapshot()?; + let digest = snapshot.save("./snapshots", "application-ready")?; + + // Later, potentially in another process. + // "application-ready" can be used instead of digest to load by tag. + let snapshot = Snapshot::load("./snapshots", digest)?; + let restorer = SandboxBuilder::new().build_from_snapshot(snapshot)?; + let _loaded = restorer.restore::()?; + Ok(()) +} +``` + +Use `restore::()` for a snapshot whose `kind()` is +`SnapshotKind::JsSandbox`. The requested type must match the snapshot kind. + +## Node.js + +```javascript +const { SandboxBuilder, Snapshot } = require('@hyperlight-dev/js-host-api'); + +const snapshot = await loaded.snapshot(); +const digest = await snapshot.save('./snapshots', 'application-ready'); + +// Later, potentially in another process. +// 'application-ready' can be used instead of digest to load by tag. +const persisted = await Snapshot.load('./snapshots', digest); +const restorer = await new SandboxBuilder().buildFromSnapshot(persisted); +const restored = await restorer.restoreLoadedSandbox(); +``` + +`build_from_snapshot()` in Rust and `buildFromSnapshot()` in Node create a +`SandboxRestorer`. Configure process-local host resources on `SandboxBuilder` +before creating the restorer, then restore the matching lifecycle type. Rust uses +`restore::()` or `restore::()`; Node uses +`restoreJsSandbox()` or `restoreLoadedSandbox()`. A restorer does not expose +`load_runtime()` or `loadRuntime()`; those methods belong to a fresh +`ProtoJSSandbox`. + +Both bindings verify every OCI blob when loading a snapshot. Use +`Snapshot::load()` in Rust or `Snapshot.load()` in Node; both accept either a +tag or manifest digest. + +For trusted local layouts, `Snapshot::load_unverified()` in Rust and +`Snapshot.loadUnverified()` in Node skip digest verification and reduce I/O. +Do not use them with untrusted snapshot data. + +## Modules and host resources + +hyperlight-js has four mechanisms that are all visible to JavaScript as modules +but have different ownership and restore behavior. + +| Category | How it is provided | Where code executes | Snapshot behavior | Restore action | +| --- | --- | --- | --- | --- | +| Host-function module | A factory that creates `HostFunctionModule`s containing Rust closures or Node callbacks | Host process through `CallHostJsFunction` | Records the builder-selected module and function names, not implementations | Supply the factory to `SandboxBuilder` | +| Embedded JavaScript module | `JSSandbox::add_module()` / `addModule()` | Guest QuickJS runtime | A `JSSandbox` snapshot stores staged source in hyperlight-js metadata. A `LoadedJSSandbox` snapshot captures the guest module-loader map and any evaluated module state. | None; hyperlight-js restores it automatically | +| External JavaScript module | Rust `SandboxBuilder::with_module_loader()` backed by `ResolveModule` and `LoadModule` | Guest QuickJS runtime after source is returned by the host | Loaded/evaluated state is captured, but source returned by the loader is not currently archived | Supply a compatible loader to the restoring builder when the underlying snapshot requires it | +| Native guest module | Rust compiled into `hyperlight-js-runtime` with `rquickjs` | Guest runtime | Native module code and state are already resident in the captured guest VM | None | + +### Reusable host-function definitions + +Define a factory once and supply it to fresh and restored builders. Each builder +invokes the factory once, creating fresh module objects, callback implementations, +and state. Create mutable state inside the factory to isolate it per sandbox. +Capturing an external `Arc`, static, or JavaScript variable deliberately shares +that state between sandboxes, which breaks the Sandbox isolation model. + +Rust: + +```rust +use hyperlight_js::{ + HostFunctionFilter, HostFunctionModule, JSSandbox, SandboxBuilder, Snapshot, +}; + +fn host_function_modules() -> Vec { + let mut database = HostFunctionModule::new("host:database"); + database.register("query", |id: u64| format!("record-{id}")); + vec![database] +} + +let fresh = SandboxBuilder::new() + .with_host_function_modules(host_function_modules) + .build()? + .load_runtime()?; + +let snapshot = Snapshot::load("./snapshots", "with-host-functions")?; +let restorer = SandboxBuilder::new() + .with_host_function_modules(host_function_modules) + .with_host_function_filter(HostFunctionFilter::snapshot_requirements()) + .build_from_snapshot(snapshot)?; +let restored: JSSandbox = restorer.restore()?; +``` + +Node.js: + +```javascript +const { + HostFunctionModule, + SandboxBuilder, + Snapshot, +} = require('@hyperlight-dev/js-host-api'); + +function hostFunctionModules() { + const database = new HostFunctionModule('database'); + database.register('query', query); + return [database]; +} + +const freshProto = await new SandboxBuilder() + .setHostFunctionModules(hostFunctionModules) + .build(); +const fresh = await freshProto.loadRuntime(); + +const snapshot = await Snapshot.load('./snapshots', 'with-host-functions'); +const restorer = await new SandboxBuilder() + .setHostFunctionModules(hostFunctionModules) + .setHostFunctionFilter('snapshotRequirements') + .buildFromSnapshot(snapshot); +const restored = await restorer.restoreJsSandbox(); +``` + +Loaded sandboxes track two host-function manifests: + +- **Required** functions belong to the currently captured guest state and are + reported by `Snapshot::requirements()` / `snapshot.requirements`. +- **Available** functions are callbacks retained by the host for dispatch and + possible future handlers after `unload()`. + +Restoring a loaded snapshot never inflates its required manifest. With the +default `HostFunctionFilter::All` / `"all"`, every supplied callback remains +available; unloading promotes that complete set into the resulting `JSSandbox`, +where newly staged handlers may import it. With `SnapshotRequirements` / +`"snapshotRequirements"`, callbacks not declared by the snapshot are discarded, +so they remain unavailable after unload. The requirements filter is valid only +with `build_from_snapshot()` / `buildFromSnapshot()`. + +For `JSSandbox`, no handlers are executing in the guest, but its snapshot still +records the host capabilities configured for the next load. It therefore records +the available manifest rather than an empty requirement set. + +hyperlight validates only the typed `CallHostJsFunction` bridge. hyperlight-js +therefore validates individual logical module and function names itself. It +cannot verify argument types, return types, or behavior; [hyperlight-js#339] +[hyperlight-js-host-function-signatures] tracks stable semantic signatures. + +### Embedded JavaScript modules + +Modules added with `add_module()` / `addModule()` are guest application state. + +For a `JSSandbox` snapshot, handlers and modules have not yet been transferred +to the guest. hyperlight-js therefore stores their source in snapshot metadata +and reconstructs the staged definitions automatically during restoration. + +Creating a `LoadedJSSandbox` transfers every embedded module source into the +guest's module-loader map before handlers are registered. A loaded snapshot +therefore captures every registered source in guest memory, including modules +that have not yet been imported or evaluated. It also captures evaluated module +instances and mutable module-scoped state. Callers do not add embedded modules +again after restoring either lifecycle state. + +### External JavaScript loaders + +The external loader is lazy. `ResolveModule` and `LoadModule` run only when +QuickJS traverses an import that earlier loaders did not satisfy. Modules never +imported before the snapshot are not present in guest memory. + +```rust +let snapshot = Snapshot::load("./snapshots", "with-module-loader")?; +let restorer = SandboxBuilder::new() + .with_module_loader(file_system) + .build_from_snapshot(snapshot)?; +let sandbox = restorer.restore::()?; +``` + +hyperlight records `ResolveModule` and `LoadModule`, including their exact type +signatures, in its snapshot. Missing or mismatched replacements produce +`SnapshotHostFunctionMismatch` before guest execution. hyperlight-js translates +that mismatch into a module-loader-specific error. Matching signatures do not +prove that a replacement loader resolves the same paths or returns the same +source. + +hyperlight-js does not currently archive source returned by an arbitrary module +loader. A future capture API must distinguish sources observed through +`LoadModule` from a complete, explicitly supplied archive: observed sources do +not cover dynamic imports that have not executed yet. + +### Native guest modules + +Native modules execute inside the guest and are already resident in guest VM +state when a snapshot is taken. They are restored with that VM state and do not +require host-side registration. The `JSRUNTIME` embedded in the current host +binary is used only when constructing a fresh sandbox, not when restoring one. +CPU architecture and hyperlight snapshot-format compatibility still apply. + +### What hyperlight #1870 blocks + +hyperlight already enforces low-level host-function requirements during restore. +[hyperlight#1870][hyperlight-snapshot-host-functions] is not needed for safety; +it is needed to inspect those requirements before attempting to build. + +Until #1870 is available, `Snapshot::requirements().module_loader()` returns +`RequirementStatus::Unknown` and `snapshot.requirements.moduleLoader` returns +`"unknown"`. hyperlight-js cannot determine in advance whether the underlying +snapshot requires `ResolveModule` and `LoadModule`. It must rely on the +application's contract or attempt restoration and translate the mismatch error. +This prevents automatic preflight selection between a captured-source loader, a +caller-supplied live loader, and no loader. It also prevents a complete public +view of future low-level hyperlight host-function requirements without +duplicating hyperlight's canonical ABI metadata. + +Logical host-function names remain inspectable because hyperlight-js owns that +metadata: + +```rust +for (module, functions) in snapshot.requirements().host_functions() { + println!("{module}: {functions:?}"); +} +``` + +`HostPrint` is handled separately. hyperlight provides a default stdout printer +when no custom handler is supplied. Reinstall a custom print handler only when +the same output routing is required. + +## Compatibility and trust + +- Snapshots are tied to their CPU architecture and hyperlight snapshot format. +- hyperlight-js metadata is schema-versioned and records the producing package + version for diagnostics. Unsupported metadata schema versions are rejected + before starting guest execution. +- Digest verification detects accidental or malicious mutation, but does not + authenticate who produced a snapshot. Authenticate snapshot artifacts through + the distribution mechanism used by your application. + +## Files in a loaded snapshot + +A persisted `LoadedJSSandbox` consists of two hyperlight snapshots in the same +OCI layout: + +- The loaded application state, including registered handlers, modules, and + mutable guest state. +- A raw runtime-ready rollback state captured before user handlers and modules + were registered. + +The rollback state is not an independently restorable hyperlight-js +`JSSandbox` snapshot: it has no `SnapshotKind::JsSandbox` lifecycle metadata or +staged handler and module definitions. The loaded snapshot's hyperlight-js +metadata stores its digest solely so `unload()` can discard evaluated handlers, +modules, and mutable guest state and return a clean `JSSandbox` after restore. + +The digest is stored only in hyperlight-js metadata, not as an OCI descriptor. +Standard OCI tools therefore do not discover or copy the baseline +automatically. [hyperlight#1865][hyperlight-snapshot-bundles] tracks native OCI +bundle support for this relationship. + +### Moving loaded snapshots with ORAS + +Until [hyperlight supports OCI snapshot bundles][hyperlight-snapshot-bundles], a +persisted `LoadedJSSandbox` uses two OCI tags: + +- `application-ready.hljs-base`: the runtime-ready baseline +- `application-ready`: the loaded guest state + +Copy both tags into the same repository or OCI layout. Push the baseline first +so the public application tag is never published without its dependency: + +```console +oras cp --from-oci-layout \ + ./snapshots:application-ready.hljs-base \ + registry.example.com/snapshots:application-ready.hljs-base + +oras cp --from-oci-layout \ + ./snapshots:application-ready \ + registry.example.com/snapshots:application-ready +``` + +Restore both tags into the same local OCI layout before loading the snapshot: + +```console +oras cp --to-oci-layout \ + registry.example.com/snapshots:application-ready.hljs-base \ + ./snapshots:application-ready.hljs-base + +oras cp --to-oci-layout \ + registry.example.com/snapshots:application-ready \ + ./snapshots:application-ready +``` + +The `oras cp --recursive` option copies OCI referrers. It does not discover the +hyperlight-js baseline because the relationship is currently stored in +application metadata rather than an OCI descriptor. + +[hyperlight-snapshot-bundles]: https://github.com/hyperlight-dev/hyperlight/issues/1865 +[hyperlight-snapshot-host-functions]: https://github.com/hyperlight-dev/hyperlight/issues/1870 +[hyperlight-js-host-function-signatures]: https://github.com/hyperlight-dev/hyperlight-js/issues/339 + +## Examples and benchmarks + +The Rust `persistent_snapshot` example and Node +`examples/persistent-snapshot.js` both have separate `write` and `read` +commands, demonstrating restoration in a new process. + +Criterion benchmarks under `persistent_snapshots` measure save, verified load, +and construction of a new `LoadedJSSandbox` from disk. diff --git a/src/js-host-api/README.md b/src/js-host-api/README.md index d1a39439..5d24df64 100644 --- a/src/js-host-api/README.md +++ b/src/js-host-api/README.md @@ -55,7 +55,10 @@ Creates and configures a new sandbox. - `setScratchSize(bytes: number)` → `this` — Set guest scratch size, includes stack (must be > 0, chainable) - `setInputBufferSize(bytes: number)` → `this` — Set guest input buffer size (must be > 0, chainable) - `setOutputBufferSize(bytes: number)` → `this` — Set guest output buffer size (must be > 0, chainable) +- `setHostFunctionModules(factory: () => HostFunctionModule[])` → `this` — Define fresh host callbacks for one sandbox +- `setHostFunctionFilter(filter: "all" | "snapshotRequirements")` → `this` — Retain all supplied callbacks across unload, or only snapshot-declared requirements - `build()` → `Promise` — Builds a proto sandbox ready to load the JavaScript runtime +- `buildFromSnapshot(snapshot)` → `Promise` — Builds a restorer using the same configured host resources ```javascript const builder = new SandboxBuilder() @@ -64,23 +67,28 @@ const builder = new SandboxBuilder() const protoSandbox = await builder.build(); ``` +When restoring a loaded snapshot, `all` keeps extra supplied callbacks available +for handlers staged after `unload()`, without adding them to the current loaded +snapshot's requirements. `snapshotRequirements` discards those extras. + ### ProtoJSSandbox -A proto sandbox ready to load the JavaScript runtime. This is also where -you register **host functions** — callbacks that guest sandboxed code can -call. See [Host Functions](#host-functions) below. +A proto sandbox is a fresh sandbox ready to load the JavaScript runtime. +Configure host functions on `SandboxBuilder` before calling `build()`. **Methods:** -- `loadRuntime()` → `Promise` — Loads the JavaScript runtime into the sandbox. All host functions registered via `hostModule()` / `register()` are applied before the runtime loads. -- `hostModule(name: string)` → `HostModule` — Create a builder for registering functions in a named module -- `register(moduleName, functionName, callback)` — Convenience method to register a single host function (args are spread, return value auto-stringified) +- `loadRuntime()` → `Promise` — Loads the JavaScript runtime into the sandbox. +- `hostModule()` / `register()` — Compatibility registration APIs; prefer a `HostFunctionModule` factory with `SandboxBuilder` so definitions can be reused safely for restore. ```javascript -// Register host functions, then load the runtime -const math = protoSandbox.hostModule('math'); -math.register('add', (a, b) => a + b); +function hostFunctionModules() { + const math = new HostFunctionModule('math'); + math.register('add', (a, b) => a + b); + return [math]; +} -const jsSandbox = await protoSandbox.loadRuntime(); +const proto = await new SandboxBuilder().setHostFunctionModules(hostFunctionModules).build(); +const jsSandbox = await proto.loadRuntime(); ``` ### JSSandbox @@ -99,6 +107,13 @@ A sandbox with the JavaScript runtime loaded, ready for handlers and modules. **Other Methods:** - `getLoadedSandbox()` → `Promise` — Registers all module sources and compiles/loads handlers into the guest; modules are compiled on-demand when first imported. +- `snapshot()` → `Promise` — Captures runtime state and staged definitions. +- `restore(snapshot)` → `Promise` — Restores runtime state and staged definitions. + +While `snapshot()` or `restore()` is running, synchronous methods and properties +fail immediately with `ERR_BUSY`. They never wait on the background VM operation +and therefore do not block the Node.js event loop. Await the operation before +accessing the same `JSSandbox` again. ```javascript // Add a module (sync) @@ -281,7 +296,9 @@ try { ### Snapshot -An opaque handle representing a point-in-time snapshot of the sandbox state. Use `snapshot()` to capture and `restore()` to roll back after a poisoned state or any other reason. +An immutable point-in-time snapshot of a `JSSandbox` or `LoadedJSSandbox`. +Use `restore()` for fast in-memory rollback, or save the snapshot to an OCI +image layout for later or cross-process restoration. ```javascript const snapshot = await loaded.snapshot(); @@ -292,6 +309,24 @@ await loaded.restore(snapshot); console.log(loaded.status); // "ready" ``` +```javascript +await snapshot.save('./snapshots', 'application-ready'); + +// Later, potentially in another process. +const persisted = await Snapshot.load('./snapshots', 'application-ready'); +const restorer = await new SandboxBuilder() + .setHostFunctionModules(hostFunctionModules) + .setHostFunctionFilter('snapshotRequirements') + .buildFromSnapshot(persisted); +const restored = await restorer.restoreLoadedSandbox(); +``` + +`JSSandbox.snapshot()` and `restorer.restoreJsSandbox()` provide the equivalent +workflow before handlers are loaded. Native callbacks are not serialized, so +the restoring builder must receive a factory that creates compatible +`HostFunctionModule`s. +See [Persistent snapshots](../../docs/persistent-snapshots.md). + ### Error Codes All errors thrown by the API include a `code` property for programmatic handling: @@ -336,9 +371,10 @@ sequenceDiagram participant Bridge as NAPI Bridge participant Host as Host Callback (Node.js) - Note over Guest,Host: Registration (before loadRuntime) - Host->>Bridge: proto.hostModule('math').register('add', callback) - Bridge->>HL: Stores closure in HostModule + Note over Guest,Host: Builder configuration + Host->>Bridge: builder.setHostFunctionModules(hostFunctionModules) + Bridge->>Bridge: Invoke factory once + Bridge->>HL: Installs selected callback closures Note over Guest,Host: Invocation (during callHandler) Guest->>HL: math.add(1, 2) @@ -350,21 +386,22 @@ sequenceDiagram HL-->>Guest: 3 ``` -1. **Register** host functions on the `ProtoJSSandbox` (before loading the runtime) +1. **Define** a reusable factory that creates fresh `HostFunctionModule` objects and supply it to `SandboxBuilder` 2. **Guest code** imports them as ES modules: `import * as math from "host:math"` 3. **At call time**, the guest's arguments are JSON-serialised and dispatched to the Node.js main thread via a threadsafe function (V8 is single-threaded, so callbacks *must* execute there). The JSON result is then returned to the guest ### Quick Start ```javascript -const { SandboxBuilder } = require('@hyperlight-dev/js-host-api'); +const { HostFunctionModule, SandboxBuilder } = require('@hyperlight-dev/js-host-api'); -const proto = await new SandboxBuilder().build(); - -// Register a sync host function — args are spread, return auto-stringified -proto.hostModule('math').register('add', (a, b) => a + b); +function hostFunctionModules() { + const math = new HostFunctionModule('math'); + math.register('add', (a, b) => a + b); + return [math]; +} -// Load the runtime (applies all registrations) +const proto = await new SandboxBuilder().setHostFunctionModules(hostFunctionModules).build(); const sandbox = await proto.loadRuntime(); // Guest code can now call math.add() @@ -416,39 +453,37 @@ db.register('query', (table) => ({ ### API Reference -#### `proto.hostModule(name)` → `HostModule` +#### `new HostFunctionModule(name)` Creates a builder for a named module. The module name is what guest code uses in its `import` statement. ```javascript -const math = proto.hostModule('math'); +const math = new HostFunctionModule('math'); // Guest: import * as math from "host:math"; ``` Throws `ERR_INVALID_ARG` if name is empty. -#### `builder.register(name, callback)` → `void` +#### `module.register(name, callback)` → `HostFunctionModule` Registers a function within the module. Arguments are auto-parsed from the guest's JSON array and spread into your callback. The return value is automatically `JSON.stringify`'d. ```javascript -const math = proto.hostModule('math'); +const math = new HostFunctionModule('math'); math.register('add', (a, b) => a + b); math.register('multiply', (a, b) => a * b); ``` Throws `ERR_INVALID_ARG` if function name is empty. -#### `proto.register(moduleName, functionName, callback)` - -Convenience shorthand — equivalent to `proto.hostModule(moduleName).register(functionName, callback)`. - -```javascript -proto.register('strings', 'upper', (s) => s.toUpperCase()); -``` +Supply the factory with `builder.setHostFunctionModules(hostFunctionModules)`. +The builder invokes it once and consumes the returned modules. Reuse the factory, +not its returned objects, for fresh or restored builders. Create mutable state +inside the factory to isolate it per sandbox. State captured from an outer scope +is deliberately shared between every sandbox using that factory. ### Async Callbacks @@ -456,7 +491,8 @@ Host function callbacks can be `async` or return a `Promise`. The bridge automatically awaits the result before returning to the guest: ```javascript -proto.hostModule('api').register('fetchUser', async (userId) => { +const api = new HostFunctionModule('api'); +api.register('fetchUser', async (userId) => { const response = await fetch(`https://api.example.com/users/${userId}`); return await response.json(); }); @@ -493,25 +529,32 @@ and arrays are also extracted into the binary sidecar and restored on the guest side. ```javascript -const { SandboxBuilder } = require('@hyperlight/js-host-api'); +const { HostFunctionModule, SandboxBuilder } = require('@hyperlight/js-host-api'); const { createHash } = require('crypto'); const zlib = require('zlib'); -const proto = await new SandboxBuilder().build(); +function hostFunctionModules() { + // Buffer arguments: guest Uint8Array → host Buffer + const crypto = new HostFunctionModule('crypto'); + crypto.register('sha256', (data) => { + // data is a Node.js Buffer + return createHash('sha256').update(data).digest(); // returns Buffer + }); -// Buffer arguments: guest Uint8Array → host Buffer -proto.hostModule('crypto').register('sha256', (data) => { - // data is a Node.js Buffer - return createHash('sha256').update(data).digest(); // returns Buffer -}); + // Mixed args: regular values and Buffers together + const io = new HostFunctionModule('io'); + io.register('compress', (algorithm, data) => { + // algorithm is a string, data is a Buffer + if (algorithm === 'gzip') return zlib.gzipSync(data); + return data; + }); -// Mixed args: regular values and Buffers together -proto.hostModule('io').register('compress', (algorithm, data) => { - // algorithm is a string, data is a Buffer - if (algorithm === 'gzip') return zlib.gzipSync(data); - return data; -}); + return [crypto, io]; +} +const proto = await new SandboxBuilder() + .setHostFunctionModules(hostFunctionModules) + .build(); const sandbox = await proto.loadRuntime(); sandbox.addHandler('handler', ` import * as crypto from "host:crypto"; @@ -549,7 +592,8 @@ If your callback throws (sync) or rejects (async), the error propagates to the guest as a `HostFunctionError`: ```javascript -proto.hostModule('auth').register('validate', (token) => { +const auth = new HostFunctionModule('auth'); +auth.register('validate', (token) => { if (!token) { throw new Error('Token is required'); } @@ -571,35 +615,43 @@ function handler(event) { ### Registration Timing -Host functions must be registered **before** calling `loadRuntime()`. -Registrations are accumulated and applied in bulk when the runtime loads. +Host-function modules must be supplied to the builder before `build()` or +`buildFromSnapshot()`. ```javascript -const proto = await new SandboxBuilder().build(); - -// ✅ Register before loadRuntime() -proto.hostModule('math').register('add', (a, b) => a + b); -proto.hostModule('strings').register('upper', (s) => s.toUpperCase()); +function hostFunctionModules() { + const math = new HostFunctionModule('math'); + math.register('add', (a, b) => a + b); + const strings = new HostFunctionModule('strings'); + strings.register('upper', (s) => s.toUpperCase()); + return [math, strings]; +} -const sandbox = await proto.loadRuntime(); // all registrations applied here +const proto = await new SandboxBuilder() + .setHostFunctionModules(hostFunctionModules) + .build(); +const sandbox = await proto.loadRuntime(); ``` ### Snapshot / Restore -Host functions survive snapshot/restore cycles. The snapshot captures the -guest micro-VM's memory — the host-side JS callbacks live in the Node.js -process and are unaffected by restore: +Host callbacks remain attached during in-memory restore because the same host +process and sandbox are reused. Persistent snapshots do not serialize callbacks; +supply the reusable module factory to the restoring builder as shown above. ```javascript -// Host-side counter — lives in Node.js, outside the micro-VM -let callCount = 0; - -const proto = await new SandboxBuilder().build(); -proto.register('stats', 'hit', () => { - callCount++; - return callCount; -}); +function hostFunctionModules() { + // This counter belongs to one sandbox because it is created inside the factory. + let callCount = 0; + const stats = new HostFunctionModule('stats'); + stats.register('hit', () => { + callCount++; + return callCount; + }); + return [stats]; +} +const proto = await new SandboxBuilder().setHostFunctionModules(hostFunctionModules).build(); const sandbox = await proto.loadRuntime(); sandbox.addHandler('handler', ` import * as stats from "host:stats"; @@ -691,7 +743,7 @@ Module imports use the format `:`: |-----------|-----------|----------------| | `user` | `addModule()` (default) | `import { add } from 'user:math'` | | Custom | `addModule(name, source, namespace)` | `import { greet } from 'mylib:utils'` | -| `host` | `hostModule().register()` (reserved) | `import * as db from 'host:db'` | +| `host` | `HostFunctionModule` (reserved) | `import * as db from 'host:db'` | The `host` namespace is reserved — you cannot use it for user modules. @@ -809,15 +861,18 @@ Timeout-based handler termination using wall-clock timeout. Demonstrates killing Combined CPU + wall-clock monitoring — the recommended pattern for comprehensive resource protection. Demonstrates OR semantics where the CPU monitor fires first for compute-bound work, with wall-clock as backstop. ### Host Functions (`host-functions.js`) -Registering sync and async host functions that guest code can call. Demonstrates -`hostModule().register()` with spread args, `async` callbacks, and the convenience -`register()` API. +Reusable sync and async `HostFunctionModule` objects supplied through +`SandboxBuilder`, with spread arguments and Promise-returning callbacks. ### User Modules (`user-modules.js`) Registering reusable ES modules that handlers can import. Demonstrates inter-module dependencies, custom namespaces, multiple handlers sharing a module, and **cross-handler mutable state sharing** via a shared counter module. +### Persistent Snapshots (`persistent-snapshot.js`) +Separate write/read processes using the same reusable host-function module +definition and `snapshotRequirements` filtering during restore. + ## Requirements - **Node.js** >= 18 diff --git a/src/js-host-api/examples/README.md b/src/js-host-api/examples/README.md index 2b541582..401d656c 100644 --- a/src/js-host-api/examples/README.md +++ b/src/js-host-api/examples/README.md @@ -117,6 +117,35 @@ Testing calculator operations: === All tests passed! === ``` +### Host Functions + +Defines a reusable factory that creates fresh `HostFunctionModule` objects for +each `SandboxBuilder` before loading the guest runtime: + +```bash +node host-functions.js +``` + +### User Modules + +Demonstrates embedded JavaScript modules, custom namespaces, dependencies, and +shared module state: + +```bash +node user-modules.js +``` + +### Persistent Snapshots + +Writes and restores a loaded snapshot in separate processes. Both processes +construct the same reusable host-function module catalog; restore filters it to +the snapshot requirements: + +```bash +node persistent-snapshot.js write ../../target/persistent-node-snapshot +node persistent-snapshot.js read ../../target/persistent-node-snapshot +``` + ## API Overview ### SandboxBuilder From d1442726674b81790ae98154ab43bcc91ace82f5 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Thu, 8 Oct 2026 22:06:34 +0100 Subject: [PATCH 3/5] Document breaking-change release labels Clarify that generated GitHub release notes use pull-request labels rather than commit-message footers, and require the breaking-change label for released API incompatibilities. Signed-off-by: Simon Davies --- docs/release.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/release.md b/docs/release.md index 8285f81d..97ebbb41 100644 --- a/docs/release.md +++ b/docs/release.md @@ -57,6 +57,17 @@ Pushing a `vX.Y.Z` tag is the **only** manual trigger you need — you do **not* Both crates.io and established npm packages use trusted publishing (OIDC), so they do not need long-lived publishing tokens. A new npm package needs a short-lived `NPM_TOKEN` for its first release, as described below. Provenance attestations are generated for every npm package. +### Release-note labels + +GitHub generates release notes from merged pull-request titles and labels using +`.github/release.yml`. Commit messages, including conventional-commit +`BREAKING CHANGE` footers, are not used for categorization. + +Any pull request that breaks a released API must have the `breaking-change` +label so it appears under **Breaking Changes**. This is in addition to the +single required `kind/*` label enforced by `PRLabelChecker.yml`. Describe the +affected Rust and Node APIs and their migration in the pull-request body. + > **Note:** Only a `vX.Y.Z` **tag** push triggers a real release. Pushing to `main`, or running the workflow manually with **Run workflow**, performs a **dry run** — it builds and validates everything but publishes nothing. ### npm trusted publishing setup From 4a047e017044826df100d9d09ee9e0ad09e983ae Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Thu, 8 Oct 2026 22:31:16 +0100 Subject: [PATCH 4/5] fix fmt issue after rebase Signed-off-by: Simon Davies --- src/hyperlight-js/tests/native_modules.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hyperlight-js/tests/native_modules.rs b/src/hyperlight-js/tests/native_modules.rs index c05b9268..872276bc 100644 --- a/src/hyperlight-js/tests/native_modules.rs +++ b/src/hyperlight-js/tests/native_modules.rs @@ -225,4 +225,4 @@ fn custom_globals_and_host_clock_work_in_vm() { assert_eq!(parsed["custom"], 42); let now = parsed["now"].as_u64().unwrap() as u128; assert!((before..=after).contains(&now)); -} \ No newline at end of file +} From 41d1fc02476c811c463a560348f85ec4dc425bfe Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Thu, 8 Oct 2026 23:04:26 +0100 Subject: [PATCH 5/5] Fix generated types and native snapshot test Import the Node URL constructor explicitly now that generated-type scripts are included in the JavaScript lint gate. Update custom native-module persistence coverage to use the lifecycle-aware snapshot restorer API. Signed-off-by: Simon Davies --- src/hyperlight-js/tests/native_modules.rs | 6 +++++- src/js-host-api/scripts/normalize-generated-types.mjs | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/hyperlight-js/tests/native_modules.rs b/src/hyperlight-js/tests/native_modules.rs index 872276bc..7e85d8c8 100644 --- a/src/hyperlight-js/tests/native_modules.rs +++ b/src/hyperlight-js/tests/native_modules.rs @@ -157,7 +157,11 @@ fn persistent_snapshot_restores_custom_native_module() { } "#, ); - + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); let directory = tempfile::tempdir().unwrap(); sandbox.add_handler("compute", handler).unwrap(); let mut loaded = sandbox.get_loaded_sandbox().unwrap(); diff --git a/src/js-host-api/scripts/normalize-generated-types.mjs b/src/js-host-api/scripts/normalize-generated-types.mjs index 0acb6d38..0492a65c 100644 --- a/src/js-host-api/scripts/normalize-generated-types.mjs +++ b/src/js-host-api/scripts/normalize-generated-types.mjs @@ -15,6 +15,7 @@ limitations under the License. */ import { readFile, writeFile } from 'node:fs/promises'; +import { URL } from 'node:url'; const declarationsPath = new URL('../index.d.ts', import.meta.url); const publicTypes = [