From 8c5221fe6a96075a9817d86dad5261050795cff3 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Thu, 8 Oct 2026 22:03:59 +0100 Subject: [PATCH 1/9] 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 --- .vscode/settings.json | 4 +- Cargo.lock | 45 +- Cargo.toml | 9 +- Justfile | 7 + 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 | 314 +++++++++- src/hyperlight-js/src/sandbox/js_sandbox.rs | 105 +++- .../src/sandbox/loaded_js_sandbox.rs | 77 ++- src/hyperlight-js/src/sandbox/mod.rs | 6 + .../src/sandbox/module_loader.rs | 87 +++ .../src/sandbox/proto_js_sandbox.rs | 118 +--- .../src/sandbox/sandbox_builder.rs | 94 ++- .../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 | 62 +- .../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 | 4 +- src/js-host-api/package.json | 18 +- .../scripts/normalize-generated-types.mjs | 40 ++ src/js-host-api/src/lib.rs | 582 +++++++++++++++--- .../tests/persistent-snapshots.test.js | 253 ++++++++ .../{vitest.config.js => vitest.config.mjs} | 2 + 33 files changed, 3396 insertions(+), 298 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/.vscode/settings.json b/.vscode/settings.json index 6a3c4a69..c242f114 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,8 @@ "hyperlight.h": "c" }, "chat.tools.terminal.autoApprove": { - "just": true + "just": true, + "gh": true, + "git rev-parse": true } } \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index bc925b37..9d9e8eab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1613,9 +1613,8 @@ dependencies = [ [[package]] name = "hyperlight-common" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "360d5876788b26af66bf055bca6b4f2c9c930a0fdcaebda4bd7f808c6bd93068" +version = "0.17.0" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -1633,24 +1632,21 @@ dependencies = [ [[package]] name = "hyperlight-guest" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faceeab52cbc0f128e75fa4b0ee9fce17c3c462d7776feca66f41e98bf2a4bb" +version = "0.17.0" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "anyhow", "flatbuffers", "hyperlight-common", "hyperlight-guest-tracing", - "itertools 0.15.0", "serde_json", "tracing", ] [[package]] name = "hyperlight-guest-bin" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7eff3c3b94ae4da51f03d535e9a13a4132f5f87694a19514c038a9517cfd750" +version = "0.17.0" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "buddy_system_allocator", "flatbuffers", @@ -1667,9 +1663,8 @@ dependencies = [ [[package]] name = "hyperlight-guest-macro" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f957baa8d97e2787589bf084a18096c08ab4dd22d4f5a040f153f9f719330ec3" +version = "0.17.0" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -1679,9 +1674,8 @@ dependencies = [ [[package]] name = "hyperlight-guest-tracing" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a12c240f87acd92e80ea08e9a405e781e4a15b9f38fee5fe261e9ce69b816d3" +version = "0.17.0" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "hyperlight-common", "spin 0.12.3", @@ -1691,9 +1685,8 @@ dependencies = [ [[package]] name = "hyperlight-host" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40f8b85ddd688890477158b9921b228973d1b70c84b866698838df0f32d2aa44" +version = "0.17.0" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "anyhow", "bindgen", @@ -1825,9 +1818,8 @@ dependencies = [ [[package]] name = "hyperlight-libc" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a74af1c1cc60140c000a6ebd4eba3121bc2b8f56f134163c25eadca1146b73e0" +version = "0.17.0" +source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" dependencies = [ "anyhow", "bindgen", @@ -2010,15 +2002,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" diff --git a/Cargo.toml b/Cargo.toml index cf5ebb0f..239492a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,11 +12,10 @@ repository = "https://github.com/hyperlight-dev/hyperlight-js" readme = "README.md" [workspace.dependencies] -# Keep the host and guest on the same release for transport compatibility. -hyperlight-guest-bin = { version = "0.18.0", features = ["libc"] } -hyperlight-host = { version = "0.18.0", default-features = false } -hyperlight-common = { version = "0.18.0", default-features = false } -hyperlight-guest = { version = "0.18.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/Cargo.toml b/src/hyperlight-js/Cargo.toml index 61516ca3..87a00833 100644 --- a/src/hyperlight-js/Cargo.toml +++ b/src/hyperlight-js/Cargo.toml @@ -23,7 +23,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" @@ -136,6 +136,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 c766d9e3..a58b2f1a 100644 --- a/src/hyperlight-js/src/sandbox/host_fn.rs +++ b/src/hyperlight-js/src/sandbox/host_fn.rs @@ -22,6 +22,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. @@ -51,25 +53,104 @@ 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, Vec, Vec) -> crate::Result> + Send + Sync + 'static +{ + move |module_name, function_name, args_json, binaries| { + let args_json = String::from_utf8(args_json).map_err(|error| { + crate::new_error!("Invalid UTF-8 in host function arguments: {error}") + })?; + 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. @@ -82,7 +163,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)?) @@ -106,7 +187,7 @@ pub(crate) fn decode_binaries(data: Vec) -> 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, } @@ -124,6 +205,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. /// @@ -164,7 +254,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 } @@ -231,8 +321,111 @@ 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::*; /// Hyperlight's cursor handles byte-sized segments and interspersed empties. @@ -256,6 +449,13 @@ mod tests { } } + 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(); @@ -270,6 +470,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 33042e0d..f3d0f43d 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::{new_error, Result, Sandbox, SandboxStatus}; +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; @@ -72,42 +76,116 @@ 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: Sandbox, + 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: Sandbox) -> 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(), }) } - /// Creates a new `JSSandbox` from a `Sandbox` and a `Snapshot` of state before any handlers were added. - pub(crate) fn from_loaded(mut loaded: Sandbox, snapshot: Arc) -> Result { + /// 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, + 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)] @@ -308,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 @@ -328,7 +417,7 @@ impl JSSandbox { )?; } - 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 452eed1d..86ce9f72 100644 --- a/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs @@ -17,9 +17,9 @@ 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::{Result, Sandbox, SandboxStatus}; +use hyperlight_host::{MultiUseSandbox, Result, SandboxStatus}; use tokio::task::JoinHandle; use tracing::{instrument, Level}; @@ -29,16 +29,23 @@ 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; /// A Hyperlight Sandbox with a JavaScript run time loaded and guest JavaScript handlers loaded. pub struct LoadedJSSandbox { - inner: Sandbox, + 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: Sandbox, 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( @@ -175,22 +211,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..ec7e765e --- /dev/null +++ b/src/hyperlight-js/src/sandbox/module_loader.rs @@ -0,0 +1,87 @@ +/* +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| { + tracing::debug!(base = %base, specifier = %specifier, "Resolving module"); + 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| { + tracing::debug!(path = %path, "Loading module"); + file_system + .read_to_string(&PathBuf::from(&path)) + .map(String::into_bytes) + .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 6b175aac..002f3154 100644 --- a/src/hyperlight-js/src/sandbox/proto_js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/proto_js_sandbox.rs @@ -18,21 +18,32 @@ use std::fmt::Debug; use std::time::SystemTime; use anyhow::Context; -use hyperlight_host::func::Bytes; 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, @@ -46,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)?; @@ -54,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(), }) } @@ -79,98 +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.into_bytes()) - }, - )?; - + 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 // chunked length-prefixed sidecar alongside the JSON args. - self.inner.register( - "CallHostJsFunction", - move |module_name: String, - func_name: String, - args_json: Vec, - binaries: Vec| - -> Result> { - let args_json = String::from_utf8(args_json).map_err(|error| { - new_error!("Invalid UTF-8 in host function arguments: {error}") - })?; - 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 996264ff..68b8df65 100644 --- a/src/hyperlight-js/src/sandbox/sandbox_builder.rs +++ b/src/hyperlight-js/src/sandbox/sandbox_builder.rs @@ -21,10 +21,14 @@ use hyperlight_common::vmem::PAGE_SIZE; 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. /// /// Transport setters record choices; [`Self::get_config`] and [`Self::build`] /// resolve buffer sizes, pool budgets, and descriptor counts. @@ -43,6 +47,9 @@ pub struct SandboxBuilder { input_transport_buffer_size: usize, /// Requested output buffer size in bytes, normalized during resolution. output_transport_buffer_size: usize, + host_function_modules: Vec, + host_function_filter: HostFunctionFilter, + module_loader: Option, } /// The minimum scratch size for the JS runtime sandbox. @@ -91,9 +98,59 @@ impl SandboxBuilder { output_transport_buffer_size: DEFAULT_TRANSPORT_BUFFER_SIZE, requested_input_pool_pages: None, requested_output_pool_pages: 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); @@ -269,8 +326,19 @@ impl SandboxBuilder { } let config = self.get_config(); + 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(config), self.host_print_fn)?; + let proto_js_sandbox = ProtoJSSandbox::new( + guest_binary, + Some(config), + self.host_print_fn, + host_modules, + self.module_loader, + )?; Ok(proto_js_sandbox) } @@ -337,6 +405,28 @@ impl SandboxBuilder { fn pages_for_buffers(count: usize, buffer_size: usize) -> usize { count.saturating_mul(buffer_size).div_ceil(PAGE_SIZE) } + + /// 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..dff43458 --- /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 8a1802ee..8883cd5f 100644 --- a/src/hyperlight-js/src/script.rs +++ b/src/hyperlight-js/src/script.rs @@ -74,6 +74,17 @@ impl Script { Arc::unwrap_or_clone(self.content).into_bytes() } + 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 e132508f..50606a88 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() { @@ -1037,3 +1042,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..872276bc 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() { 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 b55ecd3e..229415eb 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,9 +247,44 @@ 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', @@ -233,12 +295,34 @@ for (const method of [ 'setOutputTransportBufferSize', '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 a7da8907..87fd704c 100644 --- a/src/js-host-api/package-lock.json +++ b/src/js-host-api/package-lock.json @@ -3723,8 +3723,8 @@ }, "node_modules/source-map-js": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", - "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "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 f5137381..e86912b1 100644 --- a/src/js-host-api/src/lib.rs +++ b/src/js-host-api/src/lib.rs @@ -19,13 +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 hyperlight_js_common::Bytes; use napi::bindgen_prelude::{ - Buffer, FromNapiValue, JsValuesTupleIntoVec, Object, Promise, ToNapiValue, + Buffer, ClassInstance, FromNapiValue, JsValuesTupleIntoVec, Object, Promise, ToNapiValue, }; use napi::sys::{napi_env, napi_value}; use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; @@ -175,6 +175,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 { @@ -188,6 +190,7 @@ impl ErrorCode { Self::Consumed => "ERR_CONSUMED", Self::Internal => "ERR_INTERNAL", Self::Reentrant => "ERR_REENTRANT", + Self::Busy => "ERR_BUSY", } } } @@ -255,7 +258,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) } @@ -288,14 +291,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" ))); } @@ -307,7 +310,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). @@ -335,8 +338,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 @@ -353,7 +453,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 ─────────────────────────────────────────────────── @@ -493,6 +639,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 @@ -515,6 +731,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 @@ -710,6 +944,7 @@ impl ProtoJSSandboxWrapper { .map_err(join_error)??; Ok(JSSandboxWrapper { inner: Arc::new(Mutex::new(Some(js_sandbox))), + busy_flag: Arc::new(AtomicBool::new(false)), }) } @@ -775,6 +1010,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); @@ -1406,12 +1724,120 @@ 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 js_args = JsArg::from_args(args, blobs) + .map_err(|error| HyperlightError::Error(error.to_string()))?; + + 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 @@ -1438,8 +1864,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, } @@ -1487,71 +1912,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 resolves placeholders - // before dispatch and converts buffers on the JavaScript thread. - let wrapper = move |args: serde_json::Value, - blobs: Vec>| - -> hyperlight_js::Result { - use hyperlight_js::FnReturn; - use ThreadsafeFunctionCallMode::NonBlocking; - - let js_args = JsArg::from_args(args, blobs) - .map_err(|error| HyperlightError::Error(error.to_string()))?; - - 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) @@ -1581,6 +1949,7 @@ impl HostModuleWrapper { #[napi(js_name = "JSSandbox")] pub struct JSSandboxWrapper { inner: Arc>>, + busy_flag: Arc, } impl JSSandboxWrapper { @@ -1589,6 +1958,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) @@ -1599,6 +1969,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) @@ -1606,6 +1977,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())? @@ -1785,6 +2157,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 { @@ -1812,11 +2216,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. @@ -2026,14 +2451,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}" ))); } @@ -2046,7 +2471,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| { @@ -2134,6 +2559,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 4124760395fb6302a473b10d02d50b0db9b0078c Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Thu, 8 Oct 2026 22:05:40 +0100 Subject: [PATCH 2/9] 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 ddda059b..68560e13 100644 --- a/src/js-host-api/README.md +++ b/src/js-host-api/README.md @@ -59,7 +59,10 @@ Creates and configures a new sandbox. - `setOutputTransportBufferSize(bytes: number)` → `this` — Set the capacity of each large guest-to-host transport buffer (must be > 0, chainable) - `setInputBufferSize(bytes: number)` → `this` — **Deprecated:** set legacy input/host-reply capacity in bytes; use `setInputTransportPoolPages` and `setOutputTransportPoolPages` for explicit pool budgets - `setOutputBufferSize(bytes: number)` → `this` — **Deprecated:** set legacy output/host-request capacity in bytes; use `setOutputTransportPoolPages` +- `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() @@ -72,23 +75,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 @@ -107,6 +115,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) @@ -289,7 +304,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(); @@ -300,6 +317,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: @@ -344,9 +379,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) @@ -358,21 +394,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() @@ -424,39 +461,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 @@ -464,7 +499,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(); }); @@ -501,25 +537,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"; @@ -561,7 +604,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'); } @@ -583,35 +627,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"; @@ -703,7 +755,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. @@ -821,15 +873,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 94f2f98b726234e2b9034712fb97b4191221ff97 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Thu, 8 Oct 2026 22:06:34 +0100 Subject: [PATCH 3/9] 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 627fa93fa0657535a81d26817d35806f82439c29 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Thu, 8 Oct 2026 23:04:26 +0100 Subject: [PATCH 4/9] 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 = [ From c75b7439a8fe174f6340d1381d1661526a18d4ea Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Thu, 22 Oct 2026 12:29:09 +0100 Subject: [PATCH 5/9] fixes for v0.18 of hyperlight Signed-off-by: Simon Davies --- Cargo.lock | 45 +++++++++++++------ Cargo.toml | 9 ++-- src/hyperlight-js/src/sandbox/host_fn.rs | 2 +- src/hyperlight-js/src/sandbox/js_sandbox.rs | 15 +++---- .../src/sandbox/loaded_js_sandbox.rs | 8 ++-- .../src/sandbox/sandbox_restorer.rs | 2 +- 6 files changed, 48 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d9e8eab..bc925b37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1613,8 +1613,9 @@ dependencies = [ [[package]] name = "hyperlight-common" -version = "0.17.0" -source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "360d5876788b26af66bf055bca6b4f2c9c930a0fdcaebda4bd7f808c6bd93068" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -1632,21 +1633,24 @@ dependencies = [ [[package]] name = "hyperlight-guest" -version = "0.17.0" -source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faceeab52cbc0f128e75fa4b0ee9fce17c3c462d7776feca66f41e98bf2a4bb" dependencies = [ "anyhow", "flatbuffers", "hyperlight-common", "hyperlight-guest-tracing", + "itertools 0.15.0", "serde_json", "tracing", ] [[package]] name = "hyperlight-guest-bin" -version = "0.17.0" -source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7eff3c3b94ae4da51f03d535e9a13a4132f5f87694a19514c038a9517cfd750" dependencies = [ "buddy_system_allocator", "flatbuffers", @@ -1663,8 +1667,9 @@ dependencies = [ [[package]] name = "hyperlight-guest-macro" -version = "0.17.0" -source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f957baa8d97e2787589bf084a18096c08ab4dd22d4f5a040f153f9f719330ec3" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -1674,8 +1679,9 @@ dependencies = [ [[package]] name = "hyperlight-guest-tracing" -version = "0.17.0" -source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a12c240f87acd92e80ea08e9a405e781e4a15b9f38fee5fe261e9ce69b816d3" dependencies = [ "hyperlight-common", "spin 0.12.3", @@ -1685,8 +1691,9 @@ dependencies = [ [[package]] name = "hyperlight-host" -version = "0.17.0" -source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40f8b85ddd688890477158b9921b228973d1b70c84b866698838df0f32d2aa44" dependencies = [ "anyhow", "bindgen", @@ -1818,8 +1825,9 @@ dependencies = [ [[package]] name = "hyperlight-libc" -version = "0.17.0" -source = "git+https://github.com/hyperlight-dev/hyperlight?rev=fe19bf0645b9969752cf215669980fa9075dc953#fe19bf0645b9969752cf215669980fa9075dc953" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74af1c1cc60140c000a6ebd4eba3121bc2b8f56f134163c25eadca1146b73e0" dependencies = [ "anyhow", "bindgen", @@ -2002,6 +2010,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" diff --git a/Cargo.toml b/Cargo.toml index 239492a0..cf5ebb0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,10 +12,11 @@ repository = "https://github.com/hyperlight-dev/hyperlight-js" readme = "README.md" [workspace.dependencies] -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" } +# Keep the host and guest on the same release for transport compatibility. +hyperlight-guest-bin = { version = "0.18.0", features = ["libc"] } +hyperlight-host = { version = "0.18.0", default-features = false } +hyperlight-common = { version = "0.18.0", default-features = false } +hyperlight-guest = { version = "0.18.0" } 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/src/hyperlight-js/src/sandbox/host_fn.rs b/src/hyperlight-js/src/sandbox/host_fn.rs index a58b2f1a..0218e357 100644 --- a/src/hyperlight-js/src/sandbox/host_fn.rs +++ b/src/hyperlight-js/src/sandbox/host_fn.rs @@ -60,7 +60,7 @@ 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, + sandbox: &mut hyperlight_host::Sandbox, manifest: &HostFunctionManifest, ) -> crate::Result<()> { let manifest_json = serde_json::to_string(manifest)?; diff --git a/src/hyperlight-js/src/sandbox/js_sandbox.rs b/src/hyperlight-js/src/sandbox/js_sandbox.rs index f3d0f43d..0bf75fc2 100644 --- a/src/hyperlight-js/src/sandbox/js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/js_sandbox.rs @@ -18,7 +18,7 @@ use std::fmt::Debug; use std::sync::Arc; use hyperlight_host::sandbox::snapshot::Snapshot as HyperlightSnapshot; -use hyperlight_host::{new_error, MultiUseSandbox, Result, SandboxStatus}; +use hyperlight_host::{new_error, Result, Sandbox, SandboxStatus}; use tracing::{instrument, Level}; use super::host_fn::register_host_function_manifest; @@ -76,7 +76,7 @@ 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, + pub(super) inner: Sandbox, // Mutable definitions staged for guest registration. Snapshot metadata is // built from these maps only when a snapshot or LoadedJSSandbox is created. handlers: HashMap, @@ -93,10 +93,7 @@ pub struct JSSandbox { impl JSSandbox { #[instrument(err(Debug), skip(inner), level=Level::INFO)] - pub(super) fn new( - mut inner: MultiUseSandbox, - host_functions: HostFunctionManifest, - ) -> Result { + pub(super) fn new(mut inner: Sandbox, host_functions: HostFunctionManifest) -> Result { let snapshot = inner.snapshot()?; Ok(Self { inner, @@ -108,9 +105,9 @@ impl JSSandbox { }) } - /// Creates a new `JSSandbox` from a `MultiUseSandbox` and a `Snapshot` of state before any handlers were added. + /// Creates a new `JSSandbox` from a `Sandbox` and a `Snapshot` of state before any handlers were added. pub(crate) fn from_loaded( - mut loaded: MultiUseSandbox, + mut loaded: Sandbox, snapshot: Arc, host_functions: HostFunctionManifest, ) -> Result { @@ -127,7 +124,7 @@ impl JSSandbox { } pub(crate) fn from_snapshot( - inner: MultiUseSandbox, + inner: Sandbox, snapshot: &Snapshot, host_functions: HostFunctionManifest, ) -> Result { diff --git a/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs b/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs index 86ce9f72..3333ea24 100644 --- a/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs @@ -19,7 +19,7 @@ use std::sync::Arc; use hyperlight_host::hypervisor::InterruptHandle; use hyperlight_host::sandbox::snapshot::Snapshot as HyperlightSnapshot; use hyperlight_host::HyperlightError::{self, JsonConversionFailure}; -use hyperlight_host::{MultiUseSandbox, Result, SandboxStatus}; +use hyperlight_host::{Result, Sandbox, SandboxStatus}; use tokio::task::JoinHandle; use tracing::{instrument, Level}; @@ -36,7 +36,7 @@ use crate::sandbox::metrics::SandboxMetricsGuard; /// A Hyperlight Sandbox with a JavaScript run time loaded and guest JavaScript handlers loaded. pub struct LoadedJSSandbox { - inner: MultiUseSandbox, + inner: Sandbox, // 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, @@ -71,7 +71,7 @@ impl Drop for MonitorTask { impl LoadedJSSandbox { #[instrument(err(Debug), skip_all, level=Level::INFO)] pub(super) fn new( - inner: MultiUseSandbox, + inner: Sandbox, snapshot: Arc, snapshot_metadata: Arc, available_host_functions: HostFunctionManifest, @@ -89,7 +89,7 @@ impl LoadedJSSandbox { } pub(crate) fn from_snapshot( - inner: MultiUseSandbox, + inner: Sandbox, snapshot: &Snapshot, available_host_functions: HostFunctionManifest, ) -> Result { diff --git a/src/hyperlight-js/src/sandbox/sandbox_restorer.rs b/src/hyperlight-js/src/sandbox/sandbox_restorer.rs index 42fedbca..00763bf7 100644 --- a/src/hyperlight-js/src/sandbox/sandbox_restorer.rs +++ b/src/hyperlight-js/src/sandbox/sandbox_restorer.rs @@ -87,7 +87,7 @@ impl SandboxRestorer { T::restore_from(self) } - fn build(self) -> Result<(hyperlight_host::MultiUseSandbox, HostFunctionManifest)> { + fn build(self) -> Result<(hyperlight_host::Sandbox, HostFunctionManifest)> { let available_host_functions = host_function_manifest(&self.host_modules); self.snapshot .metadata() From c99ae782d76c2d499d169c99f5eaa681cd63b49d Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:26:17 -0700 Subject: [PATCH 6/9] Restore snapshots through SandboxBuilder::from_snapshot Restore a saved snapshot with SandboxBuilder::from_snapshot(snapshot).build(), matching upstream hyperlight. Remove SandboxRestorer, RestoreTarget, and build_from_snapshot(). * Snapshot is now Snapshot, where T is JSSandbox or LoadedJSSandbox. Loading checks the saved kind once. In-memory snapshot() and restore() use Snapshot, so restoring the wrong kind does not compile. Remove SnapshotKind and kind(). * SandboxBuilder is now SandboxBuilder. Every setter applies when restoring. The snapshot's layout overrides heap, scratch, and transport. Interrupt, crashdump, and debug settings are forwarded on restore. * Rename loaders to match upstream: load skips digest checks and checked_load verifies. Remove load_unverified. save takes &OciTag and returns OciDigest. Re-export OciTag, OciDigest, and OciReference. * Remove HostFunctionFilter, requirements(), SnapshotRequirements, and RequirementStatus. Restore still rejects missing host functions before running the guest, and metadata still records them. * Node: SandboxBuilder.fromSnapshot(snapshot).build() returns a JSSandbox or LoadedJSSandbox. Remove SandboxRestorer, buildFromSnapshot, setHostFunctionFilter, kind, requirements, and loadUnverified. Add checkedLoad. In-memory restore of the wrong kind fails with ERR_INVALID_ARG. TypeScript declares SandboxBuilder so fresh builds stay ProtoJSSandbox. * Remove every generated Wrapper alias before renaming, so declarations no longer contain self-aliases such as LoadedJSSandbox = LoadedJSSandbox. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- docs/persistent-snapshots.md | 149 +++--- src/hyperlight-js/benches/benchmarks.rs | 32 +- .../examples/persistent_snapshot/main.rs | 13 +- src/hyperlight-js/src/lib.rs | 16 +- src/hyperlight-js/src/sandbox/host_fn.rs | 118 +---- src/hyperlight-js/src/sandbox/js_sandbox.rs | 19 +- .../src/sandbox/loaded_js_sandbox.rs | 20 +- src/hyperlight-js/src/sandbox/mod.rs | 2 - .../src/sandbox/proto_js_sandbox.rs | 2 +- .../src/sandbox/sandbox_builder.rs | 245 ++++++---- .../src/sandbox/sandbox_restorer.rs | 147 ------ src/hyperlight-js/src/sandbox/snapshot.rs | 353 +++++++++----- src/hyperlight-js/tests/host_functions.rs | 123 +---- src/hyperlight-js/tests/module_loader.rs | 31 +- src/hyperlight-js/tests/native_modules.rs | 18 +- .../tests/persistent_snapshots.rs | 108 +++-- src/js-host-api/README.md | 34 +- src/js-host-api/examples/README.md | 3 +- .../examples/persistent-snapshot.js | 8 +- src/js-host-api/lib.js | 57 ++- src/js-host-api/lib.mjs | 2 - .../scripts/normalize-generated-types.mjs | 18 +- src/js-host-api/src/lib.rs | 437 ++++++++---------- .../tests/persistent-snapshots.test.js | 70 +-- 24 files changed, 898 insertions(+), 1127 deletions(-) delete mode 100644 src/hyperlight-js/src/sandbox/sandbox_restorer.rs diff --git a/docs/persistent-snapshots.md b/docs/persistent-snapshots.md index 827e158a..cadf2024 100644 --- a/docs/persistent-snapshots.md +++ b/docs/persistent-snapshots.md @@ -24,7 +24,7 @@ workflow. ## Rust ```rust -use hyperlight_js::{LoadedJSSandbox, SandboxBuilder, Script, Snapshot}; +use hyperlight_js::{LoadedJSSandbox, OciTag, SandboxBuilder, Script, Snapshot}; fn main() -> hyperlight_js::Result<()> { let mut sandbox = SandboxBuilder::new().build()?.load_runtime()?; @@ -35,19 +35,18 @@ fn main() -> hyperlight_js::Result<()> { let mut loaded = sandbox.get_loaded_sandbox()?; let snapshot = loaded.snapshot()?; - let digest = snapshot.save("./snapshots", "application-ready")?; + let digest = snapshot.save("./snapshots", &OciTag::new("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::()?; + // The tag can be used instead of the digest. + let snapshot = Snapshot::::checked_load("./snapshots", digest)?; + let _loaded = SandboxBuilder::from_snapshot(snapshot).build()?; Ok(()) } ``` -Use `restore::()` for a snapshot whose `kind()` is -`SnapshotKind::JsSandbox`. The requested type must match the snapshot kind. +`Snapshot` names the sandbox type the snapshot captured. Loading fails if the +saved snapshot holds the other type. `build()` returns `T`. ## Node.js @@ -59,26 +58,25 @@ 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(); +const persisted = await Snapshot.checkedLoad('./snapshots', digest); +const restored = await SandboxBuilder.fromSnapshot(persisted).build(); ``` -`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`. +`build()` returns a `JSSandbox` or `LoadedJSSandbox`, matching the saved +snapshot. TypeScript types the result as `JSSandbox | LoadedJSSandbox`. Narrow it +with `instanceof`. -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. +Configure process-local host resources on the builder before calling `build()`. +A snapshot's memory layout overrides heap, scratch, and transport settings. -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. +`Snapshot::checked_load()` in Rust and `Snapshot.checkedLoad()` in Node check +every OCI blob against its recorded digest. `Snapshot::load()` and +`Snapshot.load()` skip the check and reduce I/O. Use them only for trusted local +layouts. All four accept a tag or a manifest digest. + +The loaded blobs stay memory-mapped while the snapshot or any sandbox built from +it is alive. Overwriting, truncating, or deleting them during that time can +corrupt guest memory. ## Modules and host resources @@ -103,27 +101,24 @@ that state between sandboxes, which breaks the Sandbox isolation model. Rust: ```rust -use hyperlight_js::{ - HostFunctionFilter, HostFunctionModule, JSSandbox, SandboxBuilder, Snapshot, -}; +use hyperlight_js::{HostFunctionModule, JSSandbox, OciTag, 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 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) + .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()?; +let snapshot = + Snapshot::::checked_load("./snapshots", OciTag::new("with-host-functions")?)?; +let restored = SandboxBuilder::from_snapshot(snapshot) + .with_host_function_modules(host_function_modules) + .build()?; ``` Node.js: @@ -146,37 +141,29 @@ const freshProto = await new SandboxBuilder() .build(); const fresh = await freshProto.loadRuntime(); -const snapshot = await Snapshot.load('./snapshots', 'with-host-functions'); -const restorer = await new SandboxBuilder() +const snapshot = await Snapshot.checkedLoad('./snapshots', 'with-host-functions'); +const restored = await SandboxBuilder.fromSnapshot(snapshot) .setHostFunctionModules(hostFunctionModules) - .setHostFunctionFilter('snapshotRequirements') - .buildFromSnapshot(snapshot); -const restored = await restorer.restoreJsSandbox(); + .build(); ``` -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()`. +A snapshot records the host functions its guest state needs. `build()` fails +before running the guest if a recorded module or function is not supplied. -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()`. +The guest can call every supplied host function, including ones the snapshot +does not record. Handlers loaded after `unload()` can import them too. Supply +only the modules the guest should reach. -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. +A loaded snapshot records the host functions its evaluated handlers need. Saving +it again after a restore keeps that record, even when the restoring builder +supplied more functions. A `JSSandbox` snapshot records every function supplied +to its builder, because its staged handlers are not compiled yet. 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. +cannot verify argument types, return types, or behavior. +[hyperlight-js#339][hyperlight-js-host-function-signatures] tracks stable +semantic signatures. ### Embedded JavaScript modules @@ -200,11 +187,11 @@ 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::()?; +let snapshot = + Snapshot::::checked_load("./snapshots", OciTag::new("with-module-loader")?)?; +let sandbox = SandboxBuilder::from_snapshot(snapshot) + .with_module_loader(file_system) + .build()?; ``` hyperlight records `ResolveModule` and `LoadModule`, including their exact type @@ -227,30 +214,14 @@ 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. +### Module-loader requirements -Logical host-function names remain inspectable because hyperlight-js owns that -metadata: - -```rust -for (module, functions) in snapshot.requirements().host_functions() { - println!("{module}: {functions:?}"); -} -``` +hyperlight checks `ResolveModule` and `LoadModule` during `build()`. It does not +yet expose a snapshot's host-function requirements for inspection. +[hyperlight#1870][hyperlight-snapshot-host-functions] tracks that. Until then, +hyperlight-js cannot tell before building whether a snapshot needs a module +loader. Supply one when the application's contract requires it, or handle the +build error. `HostPrint` is handled separately. hyperlight provides a default stdout printer when no custom handler is supplied. Reinstall a custom print handler only when @@ -277,7 +248,7 @@ OCI layout: were registered. The rollback state is not an independently restorable hyperlight-js -`JSSandbox` snapshot: it has no `SnapshotKind::JsSandbox` lifecycle metadata or +`JSSandbox` snapshot: it has no hyperlight-js 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. diff --git a/src/hyperlight-js/benches/benchmarks.rs b/src/hyperlight-js/benches/benchmarks.rs index 62dc0cd0..b7dccc34 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, Snapshot}; +use hyperlight_js::{OciTag, SandboxBuilder, Script, Snapshot}; fn js_load_handler_benchmark(c: &mut Criterion) { let mut group = c.benchmark_group("js_get_loaded_sandbox"); @@ -370,23 +370,35 @@ fn persistent_snapshot_benchmark(c: &mut Criterion) { .unwrap(); let snapshot = loaded.snapshot().unwrap(); let directory = tempfile::tempdir().unwrap(); - snapshot.save(directory.path(), "benchmark").unwrap(); + snapshot + .save(directory.path(), &OciTag::new("benchmark").unwrap()) + .unwrap(); let mut group = c.benchmark_group("persistent_snapshots"); group.bench_function("save_loaded_snapshot", |b| { - b.iter(|| snapshot.save(directory.path(), "benchmark").unwrap()) + b.iter(|| { + snapshot + .save(directory.path(), &OciTag::new("benchmark").unwrap()) + .unwrap() + }) }); group.bench_function("checked_load", |b| { - b.iter(|| Snapshot::load(directory.path(), "benchmark").unwrap()) + b.iter(|| { + Snapshot::::checked_load( + directory.path(), + OciTag::new("benchmark").unwrap(), + ) + .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() + let snapshot = Snapshot::::checked_load( + directory.path(), + OciTag::new("benchmark").unwrap(), + ) + .unwrap(); + SandboxBuilder::from_snapshot(snapshot).build().unwrap() }) }); group.finish(); diff --git a/src/hyperlight-js/examples/persistent_snapshot/main.rs b/src/hyperlight-js/examples/persistent_snapshot/main.rs index b3210d78..d37ac115 100644 --- a/src/hyperlight-js/examples/persistent_snapshot/main.rs +++ b/src/hyperlight-js/examples/persistent_snapshot/main.rs @@ -17,7 +17,7 @@ limitations under the License. use std::path::PathBuf; use hyperlight_js::{ - HostFunctionFilter, HostFunctionModule, LoadedJSSandbox, SandboxBuilder, Script, Snapshot, + HostFunctionModule, LoadedJSSandbox, OciTag, SandboxBuilder, Script, Snapshot, }; fn host_function_modules() -> Vec { @@ -67,18 +67,17 @@ fn write_snapshot(path: PathBuf) -> hyperlight_js::Result<()> { println!("Before snapshot: {result}"); let snapshot = loaded.snapshot()?; - let digest = snapshot.save(path, "application-ready")?; + let digest = snapshot.save(path, &OciTag::new("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() + let snapshot = + Snapshot::::checked_load(path, OciTag::new("application-ready")?)?; + let mut loaded = SandboxBuilder::from_snapshot(snapshot) .with_host_function_modules(host_function_modules) - .with_host_function_filter(HostFunctionFilter::snapshot_requirements()) - .build_from_snapshot(snapshot)?; - let mut loaded = restorer.restore::()?; + .build()?; 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 9898cf2c..af7c5bbb 100644 --- a/src/hyperlight-js/src/lib.rs +++ b/src/hyperlight-js/src/lib.rs @@ -28,10 +28,12 @@ pub mod sandbox; use hyperlight_host::func::HostFunction; // Re-export FnReturn for the NAPI bridge (used in register_js signature). +/// OCI tags, digests, and references used to save and load snapshots. +pub use hyperlight_host::sandbox::snapshot::{OciDigest, OciReference, OciTag}; #[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}; +/// A named group of host functions that guest JavaScript imports as a module. +pub use sandbox::host_fn::HostFunctionModule; /// Validate a module identifier (name or namespace). pub use sandbox::js_sandbox::validate_module_identifier; /// Validate that a namespace is not reserved. @@ -47,12 +49,12 @@ pub use sandbox::loaded_js_sandbox::LoadedJSSandbox; /// 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 runtime. 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}; +/// Configures a new sandbox, or one restored from a [`Snapshot`]. +pub use sandbox::sandbox_builder::{NewSandbox, SandboxBuilder}; +#[doc(hidden)] +pub use sandbox::snapshot::AnySnapshot; /// A persistent snapshot of a Hyperlight JavaScript sandbox. -pub use sandbox::snapshot::{RequirementStatus, Snapshot, SnapshotKind, SnapshotRequirements}; +pub use sandbox::snapshot::{Snapshot, SnapshotTarget}; /// Types for working with JS script. pub use script::Script; /// The function to pass to a new `JSSandbox` to tell it how to handle diff --git a/src/hyperlight-js/src/sandbox/host_fn.rs b/src/hyperlight-js/src/sandbox/host_fn.rs index 0218e357..69282208 100644 --- a/src/hyperlight-js/src/sandbox/host_fn.rs +++ b/src/hyperlight-js/src/sandbox/host_fn.rs @@ -109,36 +109,6 @@ pub(crate) fn host_function_dispatch( } } -/// 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 @@ -209,11 +179,6 @@ impl HostModule { 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. /// @@ -382,42 +347,18 @@ impl std::fmt::Debug for HostFunctionModule { } } -pub(crate) fn select_host_function_modules( +pub(crate) fn host_modules_by_name( 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; + for HostFunctionModule { name, module } in modules { 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); + selected.insert(name, module); } Ok(selected) } @@ -477,8 +418,7 @@ mod tests { .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(); + let selected = host_modules_by_name(vec![module]).unwrap(); assert_eq!( host_function_manifest(&selected).get("math"), @@ -488,15 +428,14 @@ mod tests { #[test] fn repeated_factory_calls_create_independent_callback_state() { - let first = select_host_function_modules(stateful_modules(), HostFunctionFilter::All, None) + let first = host_modules_by_name(stateful_modules()) + .unwrap() + .remove("state") + .unwrap(); + let second = host_modules_by_name(stateful_modules()) .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"); @@ -515,43 +454,6 @@ mod tests { ); } - #[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![ @@ -559,7 +461,7 @@ mod tests { HostFunctionModule::new("math"), ]; - let result = select_host_function_modules(modules, HostFunctionFilter::All, None); + let result = host_modules_by_name(modules); let error = match result { Ok(_) => panic!("duplicate host-function modules were unexpectedly accepted"), Err(error) => error, diff --git a/src/hyperlight-js/src/sandbox/js_sandbox.rs b/src/hyperlight-js/src/sandbox/js_sandbox.rs index 0bf75fc2..12f37718 100644 --- a/src/hyperlight-js/src/sandbox/js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/js_sandbox.rs @@ -125,16 +125,9 @@ impl JSSandbox { pub(crate) fn from_snapshot( inner: Sandbox, - snapshot: &Snapshot, + 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(), @@ -148,7 +141,7 @@ impl JSSandbox { /// 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 { + pub fn snapshot(&mut self) -> Result> { let inner = self.inner.snapshot()?; let metadata = SnapshotMetadata::new( SnapshotKind::JsSandbox, @@ -166,13 +159,7 @@ impl JSSandbox { /// 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() - )); - } + pub fn restore(&mut self, snapshot: Snapshot) -> Result<()> { snapshot .metadata() .validate_host_functions(&self.host_functions)?; diff --git a/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs b/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs index 3333ea24..ac86c4b0 100644 --- a/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs @@ -29,7 +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}; +use super::snapshot::{HostFunctionManifest, Snapshot, SnapshotMetadata}; #[cfg(feature = "function_call_metrics")] use crate::sandbox::metrics::EventHandlerMetricGuard; use crate::sandbox::metrics::SandboxMetricsGuard; @@ -90,15 +90,9 @@ impl LoadedJSSandbox { pub(crate) fn from_snapshot( inner: Sandbox, - snapshot: &Snapshot, + 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") })?; @@ -218,7 +212,7 @@ impl LoadedJSSandbox { /// 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 { + pub fn snapshot(&mut self) -> Result> { let inner = self.inner.snapshot()?; Ok(Snapshot::new( inner, @@ -229,13 +223,7 @@ impl LoadedJSSandbox { /// Restore the state of the sandbox to a previous snapshot. #[instrument(err(Debug), skip_all, level=Level::DEBUG)] - 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() - )); - } + pub fn restore(&mut self, snapshot: Snapshot) -> Result<()> { snapshot .metadata() .validate_host_functions(&self.available_host_functions)?; diff --git a/src/hyperlight-js/src/sandbox/mod.rs b/src/hyperlight-js/src/sandbox/mod.rs index 87eaa2a1..f4986808 100644 --- a/src/hyperlight-js/src/sandbox/mod.rs +++ b/src/hyperlight-js/src/sandbox/mod.rs @@ -35,8 +35,6 @@ 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. diff --git a/src/hyperlight-js/src/sandbox/proto_js_sandbox.rs b/src/hyperlight-js/src/sandbox/proto_js_sandbox.rs index 002f3154..60bbe823 100644 --- a/src/hyperlight-js/src/sandbox/proto_js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/proto_js_sandbox.rs @@ -43,7 +43,7 @@ pub(super) fn current_time_micros() -> hyperlight_host::Result { /// 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. +/// [`JSSandbox`]. Restore snapshots with [`crate::SandboxBuilder::from_snapshot`]. pub struct ProtoJSSandbox { inner: UninitializedSandbox, host_modules: HashMap, diff --git a/src/hyperlight-js/src/sandbox/sandbox_builder.rs b/src/hyperlight-js/src/sandbox/sandbox_builder.rs index 68b8df65..57053dea 100644 --- a/src/hyperlight-js/src/sandbox/sandbox_builder.rs +++ b/src/hyperlight-js/src/sandbox/sandbox_builder.rs @@ -19,21 +19,41 @@ use std::time::Duration; use hyperlight_common::virtq::G2H_LOWER_SLOT_COUNT; use hyperlight_common::vmem::PAGE_SIZE; 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 hyperlight_host::{ + is_hypervisor_present, new_error, GuestBinary, HyperlightError, Result, + SandboxBuilder as HyperlightSandboxBuilder, +}; + +use super::host_fn::{ + host_function_dispatch, host_function_manifest, host_modules_by_name, HostFunctionModule, +}; +use super::module_loader::{ + ExternalModuleLoader, LOAD_MODULE_HOST_FUNCTION, RESOLVE_MODULE_HOST_FUNCTION, +}; +use super::proto_js_sandbox::{current_time_micros, ProtoJSSandbox}; +use super::snapshot::{restore_target, Snapshot, SnapshotTarget}; use crate::HostPrintFn; -/// Configures fresh JavaScript sandboxes and snapshot restoration. +/// Builder source for a sandbox that starts the bundled JavaScript runtime. +#[derive(Debug, Default)] +pub struct NewSandbox; + +/// Configures a new JavaScript sandbox, or one restored from a [`Snapshot`]. /// -/// Transport setters record choices; [`Self::get_config`] and [`Self::build`] -/// resolve buffer sizes, pool budgets, and descriptor counts. -pub struct SandboxBuilder { +/// [`SandboxBuilder::new`] builds a [`ProtoJSSandbox`]. +/// [`SandboxBuilder::from_snapshot`] builds the sandbox type the snapshot captured. +/// A snapshot's memory layout overrides heap, scratch, and transport settings. +/// +/// Transport setters record choices. [`Self::get_config`] and +/// [`SandboxBuilder::build`] resolve buffer sizes, pool budgets, and descriptor +/// counts. +pub struct SandboxBuilder { + source: S, base_config: SandboxConfiguration, + #[cfg(crashdump)] + guest_core_dump: Option, + #[cfg(gdb)] + guest_debug_info: Option, host_print_fn: Option, /// Requested legacy input capacity in bytes, also used for host replies. legacy_input_capacity: usize, @@ -48,7 +68,6 @@ pub struct SandboxBuilder { /// Requested output buffer size in bytes, normalized during resolution. output_transport_buffer_size: usize, host_function_modules: Vec, - host_function_filter: HostFunctionFilter, module_loader: Option, } @@ -82,15 +101,19 @@ const DEFAULT_TRANSPORT_BUFFER_SIZE: usize = 16 * 1024; /// The previous 8 KiB minimum for byte-based buffer configuration. const MIN_LEGACY_BUFFER_SIZE: usize = 8 * 1024; -impl SandboxBuilder { - /// Create a new SandboxBuilder - pub fn new() -> Self { +impl SandboxBuilder { + fn with_source(source: S) -> Self { let mut config = SandboxConfiguration::default(); config.set_heap_size(MIN_HEAP_SIZE); config.set_scratch_size(MIN_SCRATCH_SIZE); Self { + source, base_config: config, + #[cfg(crashdump)] + guest_core_dump: None, + #[cfg(gdb)] + guest_debug_info: None, host_print_fn: None, legacy_input_capacity: DEFAULT_LEGACY_BUFFER_SIZE, legacy_output_capacity: DEFAULT_LEGACY_BUFFER_SIZE, @@ -99,7 +122,6 @@ impl SandboxBuilder { requested_input_pool_pages: None, requested_output_pool_pages: None, host_function_modules: Vec::new(), - host_function_filter: HostFunctionFilter::All, module_loader: None, } } @@ -132,16 +154,6 @@ impl SandboxBuilder { 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, @@ -157,6 +169,31 @@ impl SandboxBuilder { self } + /// Sets the offset from `SIGRTMIN` to determine the real-time signal used for + /// interrupting the VCPU thread. + /// + /// The final signal number is computed as `SIGRTMIN + offset`, and it must fall within + /// the valid range of real-time signals supported by the host system. + /// + /// Returns Ok(()) if the offset is valid, or an error if it exceeds the maximum real-time signal number. + #[cfg(target_os = "linux")] + pub fn set_interrupt_vcpu_sigrtmin_offset(&mut self, offset: u8) -> Result<()> { + self.base_config + .set_interrupt_vcpu_sigrtmin_offset(offset)?; + Ok(()) + } + + /// Sets the interrupt retry delay + /// This controls the delay between sending signals to the VCPU thread to interrupt it. + /// + /// Only available for the hypervisor backends that use retrying interrupts + /// (KVM and MSHV on Linux, Hypervisor.framework on macOS). + #[cfg(any(kvm, mshv3, hvf))] + pub fn with_interrupt_retry_delay(mut self, delay: Duration) -> Self { + self.base_config.set_interrupt_retry_delay(delay); + self + } + /// Set the guest-to-host message capacity in bytes. #[deprecated(note = "Use with_output_transport_pool_pages for an explicit pool budget")] pub fn with_guest_output_buffer_size(mut self, guest_output_buffer_size: usize) -> Self { @@ -251,32 +288,8 @@ impl SandboxBuilder { self } - /// Sets the offset from `SIGRTMIN` to determine the real-time signal used for - /// interrupting the VCPU thread. - /// - /// The final signal number is computed as `SIGRTMIN + offset`, and it must fall within - /// the valid range of real-time signals supported by the host system. - /// - /// Returns Ok(()) if the offset is valid, or an error if it exceeds the maximum real-time signal number. - #[cfg(target_os = "linux")] - pub fn set_interrupt_vcpu_sigrtmin_offset(&mut self, offset: u8) -> Result<()> { - self.base_config - .set_interrupt_vcpu_sigrtmin_offset(offset)?; - Ok(()) - } - - /// Sets the interrupt retry delay - /// This controls the delay between sending signals to the VCPU thread to interrupt it. - /// - /// Only available for the hypervisor backends that use retrying interrupts - /// (KVM and MSHV on Linux, Hypervisor.framework on macOS). - #[cfg(any(kvm, mshv3, hvf))] - pub fn with_interrupt_retry_delay(mut self, delay: Duration) -> Self { - self.base_config.set_interrupt_retry_delay(delay); - self - } - - /// Get an owned snapshot of the effective configuration used by [`Self::build`]. + /// Get an owned snapshot of the effective configuration used by + /// [`SandboxBuilder::build`] for a new sandbox. /// /// Resolves transport sizes without changing the builder. Modifying the /// returned value does not affect subsequent configuration or builds. @@ -293,6 +306,7 @@ impl SandboxBuilder { #[cfg(crashdump)] pub fn with_crashdump_enabled(mut self, enabled: bool) -> Self { self.base_config.set_guest_core_dump(enabled); + self.guest_core_dump = Some(enabled); self } @@ -316,32 +330,10 @@ impl SandboxBuilder { pub fn with_debugging_enabled(mut self, port: u16) -> Self { let debug_info = hyperlight_host::sandbox::config::DebugInfo { port }; self.base_config.set_guest_debug_info(debug_info); + self.guest_debug_info = Some(debug_info); self } - /// Build the ProtoJSSandbox - pub fn build(self) -> Result { - if !is_hypervisor_present() { - return Err(HyperlightError::NoHypervisorFound()); - } - - let config = self.get_config(); - 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(config), - self.host_print_fn, - host_modules, - self.module_loader, - )?; - Ok(proto_js_sandbox) - } - /// Normalizes buffer sizes, applies explicit pool budgets or legacy /// capacities, and sizes descriptor queues from the complete buffer counts. /// Reserves a control spare and room for simultaneous callback requests/replies. @@ -405,31 +397,104 @@ impl SandboxBuilder { fn pages_for_buffers(count: usize, buffer_size: usize) -> usize { count.saturating_mul(buffer_size).div_ceil(PAGE_SIZE) } +} - /// 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 { +impl SandboxBuilder { + /// Create a builder for a new sandbox. + pub fn new() -> Self { + Self::with_source(NewSandbox) + } + + /// Build the ProtoJSSandbox + pub fn build(self) -> 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, + + let config = self.get_config(); + let host_modules = host_modules_by_name(self.host_function_modules)?; + let guest_binary = GuestBinary::Buffer(super::JSRUNTIME.to_vec()); + let proto_js_sandbox = ProtoJSSandbox::new( + guest_binary, + Some(config), self.host_print_fn, host_modules, self.module_loader, - ) + )?; + Ok(proto_js_sandbox) + } +} + +impl SandboxBuilder> { + /// Create a builder that restores `snapshot`. + /// + /// The snapshot's memory layout overrides heap, scratch, and transport + /// settings. Supply every host function and module loader the snapshot uses. + pub fn from_snapshot(snapshot: Snapshot) -> Self { + Self::with_source(snapshot) + } + + /// Restore the snapshot. + /// + /// Checks host-function requirements before running the guest. + pub fn build(self) -> Result { + if !is_hypervisor_present() { + return Err(HyperlightError::NoHypervisorFound()); + } + let snapshot = self.source; + let host_modules = host_modules_by_name(self.host_function_modules)?; + let available_host_functions = host_function_manifest(&host_modules); + snapshot + .metadata() + .validate_host_functions(&available_host_functions)?; + + let mut builder = HyperlightSandboxBuilder::from_snapshot(snapshot.inner()); + #[cfg(target_os = "linux")] + { + builder = builder.interrupt_vcpu_sigrtmin_offset( + self.base_config.get_interrupt_vcpu_sigrtmin_offset(), + )?; + } + #[cfg(any(kvm, mshv3))] + { + builder = builder.interrupt_retry_delay(self.base_config.get_interrupt_retry_delay()); + } + #[cfg(crashdump)] + if let Some(enabled) = self.guest_core_dump { + builder = builder.guest_core_dump(enabled); + } + #[cfg(gdb)] + if let Some(debug_info) = self.guest_debug_info { + builder = builder.guest_debug_info(debug_info); + } + if let Some(host_print_fn) = self.host_print_fn { + builder = builder.host_print(host_print_fn); + } + builder = builder + .host_function("CurrentTimeMicros", current_time_micros) + .host_function("CallHostJsFunction", host_function_dispatch(host_modules)); + if let Some(module_loader) = self.module_loader { + builder = module_loader.apply(builder); + } + + let sandbox = builder.build().map_err(|error| match &error { + 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 ({})", + error + ) + } + _ => error, + })?; + restore_target(sandbox, &snapshot, available_host_functions) } } -impl Default for SandboxBuilder { +impl Default for SandboxBuilder { fn default() -> Self { Self::new() } diff --git a/src/hyperlight-js/src/sandbox/sandbox_restorer.rs b/src/hyperlight-js/src/sandbox/sandbox_restorer.rs deleted file mode 100644 index 00763bf7..00000000 --- a/src/hyperlight-js/src/sandbox/sandbox_restorer.rs +++ /dev/null @@ -1,147 +0,0 @@ -/* -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::Sandbox, 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 index dff43458..af7184f4 100644 --- a/src/hyperlight-js/src/sandbox/snapshot.rs +++ b/src/hyperlight-js/src/sandbox/snapshot.rs @@ -15,16 +15,18 @@ limitations under the License. */ use std::collections::BTreeMap; +use std::marker::PhantomData; use std::path::{Path, PathBuf}; use std::sync::Arc; use hyperlight_host::sandbox::snapshot::{ OciDigest, OciReference, OciTag, Snapshot as HyperlightSnapshot, }; +use hyperlight_host::Sandbox; use serde::{Deserialize, Serialize}; use super::js_sandbox::{validate_module_identifier, validate_namespace_not_reserved}; -use crate::{new_error, Result, Script}; +use crate::{new_error, JSSandbox, LoadedJSSandbox, Result, Script}; const METADATA_NAMESPACE: &str = "dev.hyperlight.hyperlight-js.snapshot.v1"; const METADATA_SCHEMA_VERSION: u32 = 1; @@ -41,7 +43,7 @@ const MAX_LOADED_SNAPSHOT_TAG_LENGTH: usize = MAX_OCI_TAG_LENGTH - BASE_TAG_SUFF /// compatibility; callers must keep implementations compatible across restore. pub(crate) type HostFunctionManifest = BTreeMap>; -/// The Hyperlight JavaScript lifecycle state captured by a [`Snapshot`]. +/// The Hyperlight JavaScript lifecycle state recorded in snapshot metadata. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum SnapshotKind { @@ -52,45 +54,6 @@ pub enum SnapshotKind { 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 { @@ -167,13 +130,6 @@ impl SnapshotMetadata { .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 @@ -244,15 +200,213 @@ impl SnapshotMetadata { } } -/// A persistent snapshot of either a `JSSandbox` or `LoadedJSSandbox`. -#[derive(Clone)] -pub struct Snapshot { +mod private { + pub trait Sealed { + const KIND: super::SnapshotKind; + + fn from_snapshot( + inner: super::Sandbox, + snapshot: &super::Snapshot, + host_functions: super::HostFunctionManifest, + ) -> super::Result + where + Self: Sized; + } +} + +/// A sandbox type that a [`Snapshot`] can capture and restore. +/// +/// This trait is sealed and implemented only for [`JSSandbox`] and +/// [`LoadedJSSandbox`]. +pub trait SnapshotTarget: private::Sealed + Sized {} + +impl private::Sealed for JSSandbox { + const KIND: SnapshotKind = SnapshotKind::JsSandbox; + + fn from_snapshot( + inner: Sandbox, + snapshot: &Snapshot, + host_functions: HostFunctionManifest, + ) -> Result { + JSSandbox::from_snapshot(inner, snapshot, host_functions) + } +} + +impl SnapshotTarget for JSSandbox {} + +impl private::Sealed for LoadedJSSandbox { + const KIND: SnapshotKind = SnapshotKind::LoadedJsSandbox; + + fn from_snapshot( + inner: Sandbox, + snapshot: &Snapshot, + host_functions: HostFunctionManifest, + ) -> Result { + LoadedJSSandbox::from_snapshot(inner, snapshot, host_functions) + } +} + +impl SnapshotTarget for LoadedJSSandbox {} + +pub(crate) fn restore_target( + inner: Sandbox, + snapshot: &Snapshot, + host_functions: HostFunctionManifest, +) -> Result { + ::from_snapshot(inner, snapshot, host_functions) +} + +/// A persistent snapshot of a [`JSSandbox`] or [`LoadedJSSandbox`]. +/// +/// `T` is the sandbox type the snapshot restores to. Loading checks the +/// persisted kind once against `T`. +pub struct Snapshot { inner: Arc, base: Option>, metadata: Arc, + target: PhantomData T>, +} + +impl Clone for Snapshot { + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + base: self.base.clone(), + metadata: Arc::clone(&self.metadata), + target: PhantomData, + } + } +} + +impl Snapshot { + /// Load a snapshot from an OCI image layout without checking blob digests. + /// + /// Fails if the snapshot captures a sandbox type other than `T`. The memory + /// blob stays mapped while the snapshot or any sandbox built from it is + /// alive. Overwriting, truncating, or deleting the layout's blobs during + /// that time can corrupt guest memory and is undefined behavior. + pub fn load(path: impl AsRef, reference: impl Into) -> Result { + Self::load_inner(path.as_ref(), reference.into(), false) + } + + /// Load a snapshot like [`Self::load`] and check every blob against its + /// recorded sha256 digest. + /// + /// A digest check does not prove the bytes are authentic. Load only from a + /// trusted source. + pub fn checked_load( + path: impl AsRef, + reference: impl Into, + ) -> Result { + Self::load_inner(path.as_ref(), reference.into(), true) + } + + fn load_inner(path: &Path, reference: OciReference, verified: bool) -> Result { + let (inner, base, metadata) = load_parts( + path, + reference, + verified, + Some(::KIND), + )?; + Ok(Self::new(inner, base, metadata)) + } } -impl Snapshot { +type SnapshotParts = ( + Arc, + Option>, + Arc, +); + +fn load_parts( + path: &Path, + reference: OciReference, + verified: bool, + expected: Option, +) -> Result { + 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()?; + if let Some(expected) = expected + && metadata.kind() != expected + { + return Err(new_error!( + "Snapshot captures a {:?}, not a {:?}", + metadata.kind(), + expected + )); + } + + 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((inner, base, Arc::new(metadata))) +} + +/// A snapshot of either sandbox type, for bindings that cannot name `T`. +#[doc(hidden)] +#[derive(Clone)] +pub enum AnySnapshot { + /// A [`JSSandbox`] snapshot. + JsSandbox(Snapshot), + /// A [`LoadedJSSandbox`] snapshot. + LoadedJsSandbox(Snapshot), +} + +impl AnySnapshot { + /// Load a snapshot of either type, like [`Snapshot::load`]. + pub fn load(path: impl AsRef, reference: impl Into) -> Result { + Self::load_inner(path.as_ref(), reference.into(), false) + } + + /// Load a snapshot of either type, like [`Snapshot::checked_load`]. + pub fn checked_load( + path: impl AsRef, + reference: impl Into, + ) -> Result { + Self::load_inner(path.as_ref(), reference.into(), true) + } + + fn load_inner(path: &Path, reference: OciReference, verified: bool) -> Result { + let (inner, base, metadata) = load_parts(path, reference, verified, None)?; + Ok(match metadata.kind() { + SnapshotKind::JsSandbox => Self::JsSandbox(Snapshot::new(inner, base, metadata)), + SnapshotKind::LoadedJsSandbox => { + Self::LoadedJsSandbox(Snapshot::new(inner, base, metadata)) + } + }) + } + + /// Save the snapshot, like [`Snapshot::save`]. + pub fn save(&self, path: impl AsRef, tag: &OciTag) -> Result { + match self { + Self::JsSandbox(snapshot) => snapshot.save(path, tag), + Self::LoadedJsSandbox(snapshot) => snapshot.save(path, tag), + } + } +} + +impl Snapshot { pub(crate) fn new( inner: Arc, base: Option>, @@ -262,80 +416,27 @@ impl Snapshot { inner, base, metadata, + target: PhantomData, } } - /// 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())?; + /// + /// A [`LoadedJSSandbox`] snapshot also saves its runtime-ready baseline under + /// `.hljs-base`, which limits `tag` to 118 bytes. + pub fn save(&self, path: impl AsRef, tag: &OciTag) -> Result { 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_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), - }) + snapshot.save(path, tag) } pub(crate) fn inner(&self) -> Arc { @@ -465,20 +566,32 @@ mod tests { } #[test] - fn requirements_expose_host_functions_without_guessing_module_loader() { + fn host_function_validation_requires_every_recorded_function() { let mut metadata = metadata(SnapshotKind::LoadedJsSandbox); metadata.host_functions.insert( "host:database".to_owned(), vec!["execute".to_owned(), "query".to_owned()], ); + let available = |functions: &[&str]| { + HostFunctionManifest::from([( + "host:database".to_owned(), + functions.iter().map(|name| (*name).to_owned()).collect(), + )]) + }; - 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); + metadata + .validate_host_functions(&available(&["execute", "extra", "query"])) + .unwrap(); + let error = metadata + .validate_host_functions(&available(&["query"])) + .unwrap_err(); + assert!(error + .to_string() + .contains("host function 'host:database.execute'")); + let error = metadata + .validate_host_functions(&HostFunctionManifest::new()) + .unwrap_err(); + assert!(error.to_string().contains("host module 'host:database'")); } #[test] diff --git a/src/hyperlight-js/tests/host_functions.rs b/src/hyperlight-js/tests/host_functions.rs index 50606a88..8f490f0e 100644 --- a/src/hyperlight-js/tests/host_functions.rs +++ b/src/hyperlight-js/tests/host_functions.rs @@ -17,10 +17,7 @@ limitations under the License. #![allow(clippy::disallowed_macros)] -use hyperlight_js::{ - HostFunctionFilter, HostFunctionModule, LoadedJSSandbox, RequirementStatus, SandboxBuilder, - Script, Snapshot, -}; +use hyperlight_js::{HostFunctionModule, LoadedJSSandbox, SandboxBuilder, Script, Snapshot}; #[test] fn can_call_host_functions() { @@ -1062,12 +1059,6 @@ fn js_restore_rejects_missing_host_function_before_mutation() { ) .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() @@ -1145,7 +1136,7 @@ fn extra_host_function_module() -> HostFunctionModule { extra } -fn loaded_snapshot_requiring_host_function() -> Snapshot { +fn loaded_snapshot_requiring_host_function() -> Snapshot { let mut source = SandboxBuilder::new() .with_host_function_modules(|| [required_host_function_module()]) .build() @@ -1169,31 +1160,32 @@ fn loaded_snapshot_requiring_host_function() -> Snapshot { #[test] fn loaded_restore_keeps_captured_requirements_until_unload() { let snapshot = loaded_snapshot_requiring_host_function(); - let mut restored = SandboxBuilder::new() + let mut restored = SandboxBuilder::from_snapshot(snapshot) .with_host_function_modules(|| { [ required_host_function_module(), extra_host_function_module(), ] }) - .with_host_function_filter(HostFunctionFilter::all()) - .build_from_snapshot(snapshot) - .unwrap() - .restore::() + .build() .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()])]) - ); + SandboxBuilder::from_snapshot(loaded_snapshot) + .with_host_function_modules(|| [required_host_function_module()]) + .build() + .unwrap(); let mut unloaded = restored.unload().unwrap(); let unloaded_snapshot = unloaded.snapshot().unwrap(); - assert!(unloaded_snapshot - .requirements() - .host_functions() - .contains_key("extra")); + let error = match SandboxBuilder::from_snapshot(unloaded_snapshot) + .with_host_function_modules(|| [required_host_function_module()]) + .build() + { + Ok(_) => panic!("restore without the extra module unexpectedly succeeded"), + Err(error) => error, + }; + assert!(error.to_string().contains("extra")); unloaded .add_handler( @@ -1214,31 +1206,7 @@ fn loaded_restore_keeps_captured_requirements_until_unload() { } #[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() { +fn restored_js_sandbox_records_supplied_host_functions() { let source_proto = SandboxBuilder::new() .with_host_function_modules(|| [required_host_function_module()]) .build() @@ -1257,71 +1225,16 @@ fn snapshot_filter_preserves_required_capability_contract() { .unwrap(); let snapshot = source.snapshot().unwrap(); - let mut restored = SandboxBuilder::new() + let mut restored = SandboxBuilder::from_snapshot(snapshot) .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()]) diff --git a/src/hyperlight-js/tests/module_loader.rs b/src/hyperlight-js/tests/module_loader.rs index d8bb9312..93174c12 100644 --- a/src/hyperlight-js/tests/module_loader.rs +++ b/src/hyperlight-js/tests/module_loader.rs @@ -22,8 +22,8 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use hyperlight_js::{ - embed_modules, FileMetadata, FileSystem, FileSystemEmbedded, ResolveError, SandboxBuilder, - Script, Snapshot, + embed_modules, FileMetadata, FileSystem, FileSystemEmbedded, OciTag, ResolveError, + SandboxBuilder, Script, Snapshot, }; struct CloneCountingFileSystem { @@ -245,12 +245,9 @@ fn restored_sandbox_reuses_module_resolver() { 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 builder = SandboxBuilder::from_snapshot(snapshot).with_module_loader(file_system); let clones_after_setup = clone_count.load(Ordering::Relaxed); - let sandbox = restorer.restore::().unwrap(); + let sandbox = builder.build().unwrap(); let mut loaded = sandbox.get_loaded_sandbox().unwrap(); assert_eq!(clone_count.load(Ordering::Relaxed), clones_after_setup); @@ -273,9 +270,8 @@ fn restore_reports_required_module_loader() { .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::() { + let error = match SandboxBuilder::from_snapshot(snapshot).build() { Ok(_) => panic!("restore unexpectedly succeeded without a module loader"), Err(error) => error, }; @@ -307,15 +303,18 @@ fn persisted_snapshot_restores_with_a_replacement_module_loader() { sandbox.add_handler("increment", handler).unwrap(); let snapshot = sandbox.snapshot().unwrap(); let directory = tempfile::tempdir().unwrap(); - snapshot.save(directory.path(), "external-modules").unwrap(); + snapshot + .save(directory.path(), &OciTag::new("external-modules").unwrap()) + .unwrap(); - let snapshot = Snapshot::load(directory.path(), "external-modules").unwrap(); - let restorer = SandboxBuilder::new() + let snapshot = Snapshot::::checked_load( + directory.path(), + OciTag::new("external-modules").unwrap(), + ) + .unwrap(); + let mut loaded = SandboxBuilder::from_snapshot(snapshot) .with_module_loader(modules) - .build_from_snapshot(snapshot) - .unwrap(); - let mut loaded = restorer - .restore::() + .build() .unwrap() .get_loaded_sandbox() .unwrap(); diff --git a/src/hyperlight-js/tests/native_modules.rs b/src/hyperlight-js/tests/native_modules.rs index 7e85d8c8..66a7b2ed 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, Snapshot}; +use hyperlight_js::{OciTag, SandboxBuilder, Script, Snapshot}; /// Test that a custom native module ("math") can be imported and used /// from a handler running inside the Hyperlight VM. @@ -179,14 +179,18 @@ fn persistent_snapshot_restores_custom_native_module() { let snapshot = loaded.snapshot().unwrap(); snapshot - .save(directory.path(), "custom-module-ready") + .save( + directory.path(), + &OciTag::new("custom-module-ready").unwrap(), + ) .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 snapshot = Snapshot::::checked_load( + directory.path(), + OciTag::new("custom-module-ready").unwrap(), + ) + .unwrap(); + let mut restored = SandboxBuilder::from_snapshot(snapshot).build().unwrap(); let result = restored .handle_event("compute", r#"{"a":10,"b":32}"#.to_string(), None) diff --git a/src/hyperlight-js/tests/persistent_snapshots.rs b/src/hyperlight-js/tests/persistent_snapshots.rs index b02cca3e..3b686518 100644 --- a/src/hyperlight-js/tests/persistent_snapshots.rs +++ b/src/hyperlight-js/tests/persistent_snapshots.rs @@ -16,7 +16,11 @@ limitations under the License. #![allow(clippy::disallowed_macros)] -use hyperlight_js::{JSSandbox, LoadedJSSandbox, SandboxBuilder, Script, Snapshot, SnapshotKind}; +use hyperlight_js::{JSSandbox, LoadedJSSandbox, OciTag, SandboxBuilder, Script, Snapshot}; + +fn oci_tag(name: &str) -> OciTag { + OciTag::new(name).unwrap() +} fn counter_handler() -> Script { Script::from_content( @@ -42,13 +46,14 @@ fn persists_and_restores_js_sandbox() { 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 digest = snapshot + .save(directory.path(), &oci_tag("runtime-ready")) + .unwrap(); + assert!(digest.to_string().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 snapshot = + Snapshot::::checked_load(directory.path(), oci_tag("runtime-ready")).unwrap(); + let sandbox = SandboxBuilder::from_snapshot(snapshot).build().unwrap(); let mut loaded = sandbox.get_loaded_sandbox().unwrap(); let result = loaded @@ -67,12 +72,11 @@ fn js_sandbox_snapshot_accepts_maximum_oci_tag_length() { .load_runtime() .unwrap(); let snapshot = sandbox.snapshot().unwrap(); - let tag = "a".repeat(128); + let tag = oci_tag(&"a".repeat(128)); snapshot.save(directory.path(), &tag).unwrap(); - let snapshot = Snapshot::load(directory.path(), &tag).unwrap(); - assert_eq!(snapshot.kind(), SnapshotKind::JsSandbox); + Snapshot::::load(directory.path(), tag).unwrap(); } #[test] @@ -106,38 +110,44 @@ fn restores_pending_js_sandbox_state_in_memory() { } #[test] -fn rejects_wrong_persisted_snapshot_lifecycle_restore() { +fn rejects_loading_snapshot_as_wrong_lifecycle() { + let directory = tempfile::tempdir().unwrap(); let mut sandbox = SandboxBuilder::new() .build() .unwrap() .load_runtime() .unwrap(); sandbox.add_handler("counter", counter_handler()).unwrap(); - let js_snapshot = sandbox.snapshot().unwrap(); + sandbox + .snapshot() + .unwrap() + .save(directory.path(), &oci_tag("runtime-ready")) + .unwrap(); let mut loaded = sandbox.get_loaded_sandbox().unwrap(); - let loaded_snapshot = loaded.snapshot().unwrap(); - - let restorer = SandboxBuilder::new() - .build_from_snapshot(js_snapshot) + loaded + .snapshot() + .unwrap() + .save(directory.path(), &oci_tag("application-ready")) .unwrap(); - let error = match restorer.restore::() { - Ok(_) => panic!("restoring a JSSandbox snapshot as loaded should fail"), - Err(error) => error, - }; + + let error = + match Snapshot::::checked_load(directory.path(), oci_tag("runtime-ready")) + { + Ok(_) => panic!("loading a JSSandbox snapshot as loaded should fail"), + Err(error) => error, + }; assert!(error .to_string() - .contains("Cannot restore a JsSandbox snapshot as a LoadedJSSandbox")); + .contains("Snapshot captures a JsSandbox, not 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, - }; + let error = + match Snapshot::::checked_load(directory.path(), oci_tag("application-ready")) { + Ok(_) => panic!("loading a LoadedJSSandbox snapshot as runtime-ready should fail"), + Err(error) => error, + }; assert!(error .to_string() - .contains("Cannot restore a LoadedJsSandbox snapshot as a JSSandbox")); + .contains("Snapshot captures a LoadedJsSandbox, not a JsSandbox")); } #[test] @@ -158,14 +168,14 @@ fn persists_loaded_state_and_preserves_unload() { assert_eq!(result["count"], 1); let snapshot = loaded.snapshot().unwrap(); - assert_eq!(snapshot.kind(), SnapshotKind::LoadedJsSandbox); snapshot - .save(directory.path(), "application-ready") + .save(directory.path(), &oci_tag("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 snapshot = + Snapshot::::checked_load(directory.path(), oci_tag("application-ready")) + .unwrap(); + let mut loaded = SandboxBuilder::from_snapshot(snapshot).build().unwrap(); let result = loaded .handle_event("counter", "{}".to_owned(), None) @@ -203,18 +213,20 @@ fn loaded_snapshot_can_be_resaved_to_another_layout() { let snapshot = loaded.snapshot().unwrap(); let first_digest = snapshot - .save(first_directory.path(), "application-ready") + .save(first_directory.path(), &oci_tag("application-ready")) .unwrap(); - let snapshot = Snapshot::load(first_directory.path(), &first_digest).unwrap(); + let snapshot = + Snapshot::::checked_load(first_directory.path(), first_digest.clone()) + .unwrap(); let second_digest = snapshot - .save(second_directory.path(), "application-copy") + .save(second_directory.path(), &oci_tag("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 snapshot = + Snapshot::::checked_load(second_directory.path(), second_digest).unwrap(); + let mut loaded = SandboxBuilder::from_snapshot(snapshot).build().unwrap(); let result = loaded .handle_event("counter", "{}".to_owned(), None) .unwrap(); @@ -242,9 +254,9 @@ fn loaded_snapshot_rejects_tag_that_would_collide_after_truncation() { 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 tag = oci_tag(&"a".repeat(119)); - let error = snapshot.save(directory.path(), tag).unwrap_err(); + 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()); @@ -277,11 +289,13 @@ fn persisted_js_sandbox_restores_embedded_javascript_modules() { .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::() + snapshot + .save(directory.path(), &oci_tag("embedded-js")) + .unwrap(); + let snapshot = + Snapshot::::checked_load(directory.path(), oci_tag("embedded-js")).unwrap(); + let mut loaded = SandboxBuilder::from_snapshot(snapshot) + .build() .unwrap() .get_loaded_sandbox() .unwrap(); diff --git a/src/js-host-api/README.md b/src/js-host-api/README.md index 68560e13..5e0e462e 100644 --- a/src/js-host-api/README.md +++ b/src/js-host-api/README.md @@ -60,9 +60,10 @@ Creates and configures a new sandbox. - `setInputBufferSize(bytes: number)` → `this` — **Deprecated:** set legacy input/host-reply capacity in bytes; use `setInputTransportPoolPages` and `setOutputTransportPoolPages` for explicit pool budgets - `setOutputBufferSize(bytes: number)` → `this` — **Deprecated:** set legacy output/host-request capacity in bytes; use `setOutputTransportPoolPages` - `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 + +**Static methods:** +- `SandboxBuilder.fromSnapshot(snapshot)` → `SandboxBuilder` — Creates a builder whose `build()` returns the `JSSandbox` or `LoadedJSSandbox` the snapshot captured. The snapshot's memory layout overrides heap, scratch, and transport settings. ```javascript const builder = new SandboxBuilder() @@ -75,10 +76,6 @@ 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 is a fresh sandbox ready to load the JavaScript runtime. @@ -321,18 +318,20 @@ console.log(loaded.status); // "ready" await snapshot.save('./snapshots', 'application-ready'); // Later, potentially in another process. -const persisted = await Snapshot.load('./snapshots', 'application-ready'); -const restorer = await new SandboxBuilder() +const persisted = await Snapshot.checkedLoad('./snapshots', 'application-ready'); +const restored = await SandboxBuilder.fromSnapshot(persisted) .setHostFunctionModules(hostFunctionModules) - .setHostFunctionFilter('snapshotRequirements') - .buildFromSnapshot(persisted); -const restored = await restorer.restoreLoadedSandbox(); + .build(); ``` -`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. +`Snapshot.checkedLoad()` checks every blob against its recorded digest. +`Snapshot.load()` skips the check. Use it only for trusted local layouts. + +The same workflow restores a `JSSandbox` snapshot taken before handlers are +loaded. Native callbacks are not serialized, so the restoring builder must +receive a factory that creates compatible `HostFunctionModule`s. Restoring a +snapshot of the other sandbox type into an existing sandbox fails with +`ERR_INVALID_ARG`. See [Persistent snapshots](../../docs/persistent-snapshots.md). ### Error Codes @@ -627,8 +626,7 @@ function handler(event) { ### Registration Timing -Host-function modules must be supplied to the builder before `build()` or -`buildFromSnapshot()`. +Host-function modules must be supplied to the builder before `build()`. ```javascript function hostFunctionModules() { @@ -883,7 +881,7 @@ dependencies, custom namespaces, multiple handlers sharing a module, and ### Persistent Snapshots (`persistent-snapshot.js`) Separate write/read processes using the same reusable host-function module -definition and `snapshotRequirements` filtering during restore. +definition. ## Requirements diff --git a/src/js-host-api/examples/README.md b/src/js-host-api/examples/README.md index 401d656c..4afd9082 100644 --- a/src/js-host-api/examples/README.md +++ b/src/js-host-api/examples/README.md @@ -138,8 +138,7 @@ 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: +construct the same reusable host-function module catalog: ```bash node persistent-snapshot.js write ../../target/persistent-node-snapshot diff --git a/src/js-host-api/examples/persistent-snapshot.js b/src/js-host-api/examples/persistent-snapshot.js index baa54e1b..801b89ec 100644 --- a/src/js-host-api/examples/persistent-snapshot.js +++ b/src/js-host-api/examples/persistent-snapshot.js @@ -50,12 +50,10 @@ async function writeSnapshot() { } async function readSnapshot() { - const snapshot = await Snapshot.load(path, 'application-ready'); - const restorer = await new SandboxBuilder() + const snapshot = await Snapshot.checkedLoad(path, 'application-ready'); + const loaded = await SandboxBuilder.fromSnapshot(snapshot) .setHostFunctionModules(hostFunctionModules) - .setHostFunctionFilter('snapshotRequirements') - .buildFromSnapshot(snapshot); - const loaded = await restorer.restoreLoadedSandbox(); + .build(); console.log('After restore:', await loaded.callHandler('counter', {})); await loaded.dispose(); } diff --git a/src/js-host-api/lib.js b/src/js-host-api/lib.js index 229415eb..669eae83 100644 --- a/src/js-host-api/lib.js +++ b/src/js-host-api/lib.js @@ -121,7 +121,6 @@ const { LoadedJSSandbox, JSSandbox, ProtoJSSandbox, - SandboxRestorer, SandboxBuilder, Snapshot, HostFunctionModule, @@ -192,15 +191,6 @@ for (const method of ['loadRuntime']) { 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); @@ -262,29 +252,39 @@ const HostFunctionModuleExport = new Proxy(HostFunctionModule, { native.HostFunctionModule = HostFunctionModuleExport; native.HostFunctionModuleWrapper = HostFunctionModuleExport; -// SandboxBuilder — async build + sync setters -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); +/** + * Replace a native class export with one whose static factories have + * enriched errors. Native static properties cannot be reassigned. + * + * @param {Function} cls — the native class + * @param {Object} statics — wrapped static methods keyed by name + * @returns {Function} the class to export + */ +function withWrappedStatics(cls, statics) { + function ClassExport(...args) { + return Reflect.construct(cls, args, cls); + } + ClassExport.prototype = cls.prototype; + Object.setPrototypeOf(ClassExport, cls); + Object.defineProperties( + ClassExport, + Object.fromEntries(Object.entries(statics).map(([name, value]) => [name, { value }])) + ); + return ClassExport; } +// SandboxBuilder — async build, static snapshot factory, sync setters +SandboxBuilder.prototype.build = wrapAsync(SandboxBuilder.prototype.build); +native.SandboxBuilder = withWrappedStatics(SandboxBuilder, { + fromSnapshot: wrapSync(SandboxBuilder.fromSnapshot.bind(SandboxBuilder)), +}); + // 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 = withWrappedStatics(Snapshot, { + load: wrapAsync(Snapshot.load.bind(Snapshot)), + checkedLoad: wrapAsync(Snapshot.checkedLoad.bind(Snapshot)), }); -native.Snapshot = SnapshotExport; for (const method of [ 'setHeapSize', @@ -295,7 +295,6 @@ for (const method of [ 'setOutputTransportBufferSize', 'setInputBufferSize', 'setOutputBufferSize', - 'setHostFunctionFilter', ]) { const orig = SandboxBuilder.prototype[method]; if (!orig) throw new Error(`Cannot wrap missing method: SandboxBuilder.${method}`); diff --git a/src/js-host-api/lib.mjs b/src/js-host-api/lib.mjs index f0e59a73..73aa4381 100644 --- a/src/js-host-api/lib.mjs +++ b/src/js-host-api/lib.mjs @@ -29,10 +29,8 @@ export const { LoadedJSSandbox, ProtoJSSandbox, SandboxBuilder, - SandboxRestorer, SandboxStatus, Snapshot, - SnapshotRequirementStatus, } = native; export default native; diff --git a/src/js-host-api/scripts/normalize-generated-types.mjs b/src/js-host-api/scripts/normalize-generated-types.mjs index 0492a65c..b50a591c 100644 --- a/src/js-host-api/scripts/normalize-generated-types.mjs +++ b/src/js-host-api/scripts/normalize-generated-types.mjs @@ -26,16 +26,30 @@ const publicTypes = [ 'LoadedJSSandbox', 'ProtoJSSandbox', 'SandboxBuilder', - 'SandboxRestorer', 'Snapshot', ]; let declarations = await readFile(declarationsPath, 'utf8'); +// Remove every alias first. Renaming `JSSandboxWrapper` would otherwise also +// rewrite `LoadedJSSandboxWrapper` before its own alias is removed. for (const publicType of publicTypes) { declarations = declarations.replace( new RegExp(`^export type ${publicType}Wrapper = ${publicType}\\r?\\n`, 'm'), '' ); - declarations = declarations.replaceAll(`${publicType}Wrapper`, publicType); } +for (const publicType of publicTypes) { + declarations = declarations.replace(new RegExp(`\\b${publicType}Wrapper\\b`, 'g'), publicType); +} + +// `build()` returns the sandbox type selected by `new SandboxBuilder()` or +// `SandboxBuilder.fromSnapshot()`. napi cannot declare class type parameters. +const builderClass = /^export declare class SandboxBuilder \{/m; +if (!builderClass.test(declarations)) { + throw new Error('Generated declarations do not declare class SandboxBuilder'); +} +declarations = declarations.replace( + builderClass, + 'export declare class SandboxBuilder {' +); await writeFile(declarationsPath, declarations); diff --git a/src/js-host-api/src/lib.rs b/src/js-host-api/src/lib.rs index e86912b1..fe726c2b 100644 --- a/src/js-host-api/src/lib.rs +++ b/src/js-host-api/src/lib.rs @@ -19,13 +19,14 @@ use std::time::Duration; use arc_swap::ArcSwapOption; use hyperlight_js::{ - CpuTimeMonitor, ExecutionStats, HostFunctionFilter, HostFunctionModule, HyperlightError, - InterruptHandle, JSSandbox, LoadedJSSandbox, ProtoJSSandbox, SandboxBuilder, SandboxRestorer, - SandboxStatus as HyperlightSandboxStatus, Script, Snapshot, WallClockMonitor, + AnySnapshot, CpuTimeMonitor, ExecutionStats, HostFunctionModule, HyperlightError, + InterruptHandle, JSSandbox, LoadedJSSandbox, OciReference, OciTag, ProtoJSSandbox, + SandboxBuilder, SandboxStatus as HyperlightSandboxStatus, Script, Snapshot, WallClockMonitor, }; use hyperlight_js_common::Bytes; use napi::bindgen_prelude::{ - Buffer, ClassInstance, FromNapiValue, JsValuesTupleIntoVec, Object, Promise, ToNapiValue, + Buffer, ClassInstance, Either3, FromNapiValue, JsValuesTupleIntoVec, Object, Promise, + ToNapiValue, }; use napi::sys::{napi_env, napi_value}; use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; @@ -365,83 +366,12 @@ impl Drop for JSSandboxBusyGuard { // ── 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. +/// A captured point-in-time state of a `JSSandbox` or `LoadedJSSandbox`. /// /// Take a snapshot before risky operations (e.g., running untrusted code /// with monitors). If the sandbox becomes poisoned, restore from the -/// snapshot to recover. +/// snapshot to recover. Restore a saved snapshot into a new sandbox with +/// `SandboxBuilder.fromSnapshot()`. /// /// ```js /// const snapshot = await loaded.snapshot(); @@ -453,48 +383,61 @@ fn snapshot_requirements( /// ``` #[napi(js_name = "Snapshot")] pub struct SnapshotWrapper { - inner: Snapshot, + inner: AnySnapshot, +} + +fn parse_reference(reference: &str) -> napi::Result { + reference + .parse() + .map_err(|error: HyperlightError| invalid_arg_error(error)) } #[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. + /// Save this snapshot to an OCI image layout and return its manifest digest. + /// + /// A `LoadedJSSandbox` snapshot also saves its runtime-ready baseline under + /// `.hljs-base`, which limits `tag` to 118 bytes. #[napi] pub async fn save(&self, path: String, tag: String) -> napi::Result { + let tag = OciTag::new(tag).map_err(invalid_arg_error)?; let snapshot = self.inner.clone(); - tokio::task::spawn_blocking(move || snapshot.save(path, tag).map_err(to_napi_error)) - .await - .map_err(join_error)? + tokio::task::spawn_blocking(move || { + snapshot + .save(path, &tag) + .map(|digest| digest.to_string()) + .map_err(to_napi_error) + }) + .await + .map_err(join_error)? } - /// Load and verify a snapshot from an OCI image layout. + /// Load a snapshot from an OCI image layout without checking blob digests. + /// + /// The layout's blobs stay memory-mapped while the snapshot or any sandbox + /// built from it is alive. Overwriting, truncating, or deleting them during + /// that time can corrupt guest memory. #[napi(factory)] pub async fn load(path: String, reference: String) -> napi::Result { + let reference = parse_reference(&reference)?; let snapshot = tokio::task::spawn_blocking(move || { - Snapshot::load(path, reference).map_err(to_napi_error) + AnySnapshot::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. + /// Load a snapshot like `load()` and check every blob against its recorded + /// sha256 digest. + /// + /// A digest check does not prove the bytes are authentic. Load only from a + /// trusted source. #[napi(factory)] - pub async fn load_unverified(path: String, reference: String) -> napi::Result { + pub async fn checked_load(path: String, reference: String) -> napi::Result { + let reference = parse_reference(&reference)?; let snapshot = tokio::task::spawn_blocking(move || { - Snapshot::load_unverified(path, reference).map_err(to_napi_error) + AnySnapshot::checked_load(path, reference).map_err(to_napi_error) }) .await .map_err(join_error)??; @@ -504,20 +447,42 @@ impl SnapshotWrapper { // ── SandboxBuilder ─────────────────────────────────────────────────── -/// Configures and creates a new sandbox. +/// The sandbox a `SandboxBuilder` creates. +enum BuilderState { + New(SandboxBuilder), + JsSandbox(SandboxBuilder>), + LoadedJsSandbox(SandboxBuilder>), +} + +/// Apply one builder setter to whichever sandbox the builder creates. +macro_rules! map_builder { + ($state:expr, $builder:ident => $body:expr) => { + match $state { + BuilderState::New($builder) => BuilderState::New($body), + BuilderState::JsSandbox($builder) => BuilderState::JsSandbox($body), + BuilderState::LoadedJsSandbox($builder) => BuilderState::LoadedJsSandbox($body), + } + }; +} + +/// Configures and creates a new sandbox, or restores one from a snapshot. /// /// Use the builder to set memory limits before constructing the sandbox. -/// All size setters support method chaining. +/// All size setters support method chaining. A snapshot's memory layout +/// overrides heap, scratch, and transport settings. /// /// ```js /// const proto = await new SandboxBuilder() /// .setHeapSize(8 * 1024 * 1024) /// .setScratchSize(1024 * 1024) /// .build(); +/// +/// const snapshot = await Snapshot.checkedLoad('./snapshots', 'app'); +/// const restored = await SandboxBuilder.fromSnapshot(snapshot).build(); /// ``` #[napi(js_name = "SandboxBuilder")] pub struct SandboxBuilderWrapper { - inner: Arc>>, + inner: Arc>>, } impl Default for SandboxBuilderWrapper { @@ -531,7 +496,7 @@ impl SandboxBuilderWrapper { /// consumed (after `build()` has been called). fn with_inner(&self, f: F) -> napi::Result<&Self> where - F: FnOnce(SandboxBuilder) -> SandboxBuilder, + F: FnOnce(BuilderState) -> BuilderState, { let mut guard = self.inner.lock().map_err(|_| lock_error())?; let builder = guard @@ -542,7 +507,7 @@ impl SandboxBuilderWrapper { } /// Take ownership of the inner builder, or error if consumed. - fn take_inner(&self) -> napi::Result { + fn take_inner(&self) -> napi::Result { self.inner .lock() .map_err(|_| lock_error())? @@ -557,7 +522,29 @@ impl SandboxBuilderWrapper { #[napi(constructor)] pub fn new() -> Self { Self { - inner: Arc::new(Mutex::new(Some(SandboxBuilder::new()))), + inner: Arc::new(Mutex::new(Some(BuilderState::New(SandboxBuilder::new())))), + } + } + + /// Create a builder that restores `snapshot`. + /// + /// `build()` returns the sandbox type the snapshot captured. Supply every + /// host function the snapshot uses. + #[napi( + factory, + ts_return_type = "SandboxBuilder" + )] + pub fn from_snapshot(snapshot: &SnapshotWrapper) -> Self { + let state = match &snapshot.inner { + AnySnapshot::JsSandbox(snapshot) => { + BuilderState::JsSandbox(SandboxBuilder::from_snapshot(snapshot.clone())) + } + AnySnapshot::LoadedJsSandbox(snapshot) => { + BuilderState::LoadedJsSandbox(SandboxBuilder::from_snapshot(snapshot.clone())) + } + }; + Self { + inner: Arc::new(Mutex::new(Some(state))), } } @@ -580,7 +567,9 @@ impl SandboxBuilderWrapper { )); } - self.with_inner(|b| b.with_guest_output_buffer_size(size as usize)) + self.with_inner( + |state| map_builder!(state, b => b.with_guest_output_buffer_size(size as usize)), + ) } /// Set the legacy host-to-guest message capacity in bytes. @@ -602,7 +591,9 @@ impl SandboxBuilderWrapper { )); } - self.with_inner(|b| b.with_guest_input_buffer_size(size as usize)) + self.with_inner( + |state| map_builder!(state, b => b.with_guest_input_buffer_size(size as usize)), + ) } /// Set the guest scratch size in bytes. @@ -619,7 +610,7 @@ impl SandboxBuilderWrapper { if size == 0 { return Err(invalid_arg_error("Scratch size must be greater than 0")); } - self.with_inner(|b| b.with_guest_scratch_size(size as usize)) + self.with_inner(|state| map_builder!(state, b => b.with_guest_scratch_size(size as usize))) } /// Set the guest heap size in bytes. @@ -636,7 +627,7 @@ impl SandboxBuilderWrapper { if size == 0 { return Err(invalid_arg_error("Heap size must be greater than 0")); } - self.with_inner(|b| b.with_guest_heap_size(size as u64)) + self.with_inner(|state| map_builder!(state, b => b.with_guest_heap_size(size as u64))) } /// Supply host-function modules for this sandbox. @@ -687,66 +678,47 @@ impl SandboxBuilderWrapper { let builder = builder_guard .take() .ok_or_else(|| consumed_error("SandboxBuilder"))?; - *builder_guard = Some(builder.with_host_function_modules(move || modules)); + *builder_guard = Some(map_builder!( + builder, + b => b.with_host_function_modules(|| 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. + /// Build the sandbox from this builder's configuration. /// - /// This allocates the sandbox VM resources. The builder is consumed - /// and cannot be reused after calling this method. + /// A new builder returns a `ProtoJSSandbox`. A builder from + /// `SandboxBuilder.fromSnapshot()` returns the sandbox type the snapshot + /// captured. The builder is consumed and cannot be reused. /// /// This is an async operation — it returns a `Promise` and does not /// block the Node.js event loop. /// - /// @returns A `Promise` ready to load the JavaScript runtime - /// @throws On resource allocation failure, or if already consumed - #[napi] - pub async fn build(&self) -> napi::Result { - let builder = self.take_inner()?; - let proto_sandbox = - tokio::task::spawn_blocking(move || builder.build().map_err(to_napi_error)) - .await - .map_err(join_error)??; - Ok(ProtoJSSandboxWrapper { - inner: Arc::new(Mutex::new(Some(proto_sandbox))), - }) - } - - /// Build a restorer configured for the supplied snapshot. - #[napi] - pub async fn build_from_snapshot( + /// @throws On resource allocation failure, missing host functions, or if already consumed + #[napi(ts_return_type = "Promise")] + pub async fn build( &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) + ) -> napi::Result> + { + let state = self.take_inner()?; + tokio::task::spawn_blocking(move || match state { + BuilderState::New(builder) => { + let proto_sandbox = builder.build().map_err(to_napi_error)?; + Ok(Either3::A(ProtoJSSandboxWrapper { + inner: Arc::new(Mutex::new(Some(proto_sandbox))), + })) + } + BuilderState::JsSandbox(builder) => { + let sandbox = builder.build().map_err(to_napi_error)?; + Ok(Either3::B(JSSandboxWrapper::from_sandbox(sandbox))) + } + BuilderState::LoadedJsSandbox(builder) => { + let sandbox = builder.build().map_err(to_napi_error)?; + Ok(Either3::C(LoadedJSSandboxWrapper::from_sandbox(sandbox))) + } }) .await - .map_err(join_error)??; - Ok(SandboxRestorerWrapper { - inner: Arc::new(Mutex::new(Some(restorer))), - }) + .map_err(join_error)? } /// Set a callback that receives guest `console.log` / `print` output. @@ -772,7 +744,7 @@ impl SandboxBuilderWrapper { false, // Not accepting unknown return types >, ) -> napi::Result<&Self> { - self.with_inner(|b| { + self.with_inner(|state| { // Blocking mode ensures the TSFN call is queued even when the // queue is full (it blocks until space is available), preventing // silent message drops that NonBlocking mode would cause. @@ -794,7 +766,7 @@ impl SandboxBuilderWrapper { -1 } }; - b.with_host_print_fn(print_fn.into()) + map_builder!(state, b => b.with_host_print_fn(print_fn.into())) }) } @@ -816,7 +788,9 @@ impl SandboxBuilderWrapper { )); } - self.with_inner(|b| b.with_output_transport_pool_pages(pages as usize)) + self.with_inner( + |state| map_builder!(state, b => b.with_output_transport_pool_pages(pages as usize)), + ) } /// Set the total host-to-guest buffer pool size in 4 KiB pages. @@ -834,7 +808,9 @@ impl SandboxBuilderWrapper { return Err(invalid_arg_error("Input pool pages must be greater than 0")); } - self.with_inner(|b| b.with_input_transport_pool_pages(pages as usize)) + self.with_inner( + |state| map_builder!(state, b => b.with_input_transport_pool_pages(pages as usize)), + ) } /// Set each large guest-to-host transport buffer's capacity in bytes. @@ -852,7 +828,9 @@ impl SandboxBuilderWrapper { )); } - self.with_inner(|b| b.with_output_transport_buffer_size(size as usize)) + self.with_inner( + |state| map_builder!(state, b => b.with_output_transport_buffer_size(size as usize)), + ) } /// Set each host-to-guest transport buffer's capacity in bytes. @@ -870,7 +848,9 @@ impl SandboxBuilderWrapper { )); } - self.with_inner(|b| b.with_input_transport_buffer_size(size as usize)) + self.with_inner( + |state| map_builder!(state, b => b.with_input_transport_buffer_size(size as usize)), + ) } } @@ -1010,89 +990,6 @@ 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); @@ -1953,6 +1850,13 @@ pub struct JSSandboxWrapper { } impl JSSandboxWrapper { + fn from_sandbox(sandbox: JSSandbox) -> Self { + Self { + inner: Arc::new(Mutex::new(Some(sandbox))), + busy_flag: Arc::new(AtomicBool::new(false)), + } + } + /// Borrow the inner value mutably via Mutex, or error if consumed. fn with_inner_mut(&self, f: F) -> napi::Result where @@ -2170,16 +2074,23 @@ impl JSSandboxWrapper { }) .await .map_err(join_error)??; - Ok(SnapshotWrapper { inner: snapshot }) + Ok(SnapshotWrapper { + inner: AnySnapshot::JsSandbox(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 AnySnapshot::JsSandbox(snapshot) = &snapshot.inner else { + return Err(invalid_arg_error( + "Cannot restore a LoadedJSSandbox snapshot into a JSSandbox", + )); + }; + let snapshot = snapshot.clone(); 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"))?; @@ -2309,6 +2220,21 @@ pub struct LoadedJSSandboxWrapper { type LoadedJSSandboxGuard = OwnedMappedMutexGuard, LoadedJSSandbox>; impl LoadedJSSandboxWrapper { + fn from_sandbox(sandbox: LoadedJSSandbox) -> Self { + let interrupt = sandbox.interrupt_handle(); + let status = Arc::new(AtomicU8::new( + SandboxStatus::from_hyperlight(sandbox.status()).as_u8(), + )); + Self { + inner: Arc::new(AsyncMutex::new(Some(sandbox))), + interrupt, + status, + last_call_stats: Arc::new(ArcSwapOption::empty()), + disposed_flag: Arc::new(AtomicBool::new(false)), + executing_flag: Arc::new(AtomicBool::new(false)), + } + } + /// Borrow the inner value mutably via Mutex, or error if consumed. /// /// Performs deadlock detection: if `executing_flag` is set (meaning we're @@ -2683,7 +2609,9 @@ impl LoadedJSSandboxWrapper { result }) .await?; - Ok(SnapshotWrapper { inner: snapshot }) + Ok(SnapshotWrapper { + inner: AnySnapshot::LoadedJsSandbox(snapshot), + }) } /// Restore the sandbox to a previously captured snapshot state. @@ -2697,7 +2625,12 @@ impl LoadedJSSandboxWrapper { /// @throws If the snapshot doesn't match this sandbox, or if consumed #[napi] pub async fn restore(&self, snapshot: &SnapshotWrapper) -> napi::Result<()> { - let snap = snapshot.inner.clone(); + let AnySnapshot::LoadedJsSandbox(snap) = &snapshot.inner else { + return Err(invalid_arg_error( + "Cannot restore a JSSandbox snapshot into a LoadedJSSandbox", + )); + }; + let snap = snap.clone(); let status = self.status.clone(); self.with_blocking_inner(move |mut sandbox| { diff --git a/src/js-host-api/tests/persistent-snapshots.test.js b/src/js-host-api/tests/persistent-snapshots.test.js index 48ea84dd..a32b6813 100644 --- a/src/js-host-api/tests/persistent-snapshots.test.js +++ b/src/js-host-api/tests/persistent-snapshots.test.js @@ -21,7 +21,13 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { URL } from 'node:url'; -import { HostFunctionModule, SandboxBuilder, Snapshot } from '../lib.js'; +import { + HostFunctionModule, + JSSandbox, + LoadedJSSandbox, + SandboxBuilder, + Snapshot, +} from '../lib.js'; const directories = []; @@ -49,9 +55,9 @@ describe('Persistent snapshots', () => { expect(esm.HostFunctionModule).toBe(HostFunctionModule); expect(Object.keys(esm).some((name) => name.endsWith('Wrapper'))).toBe(false); - expect(typeof esm.SandboxRestorer).toBe('function'); + expect(esm.SandboxBuilder).toBe(SandboxBuilder); + expect(typeof esm.SandboxBuilder.fromSnapshot).toBe('function'); expect(esm.SandboxStatus).toBeDefined(); - expect(esm.SnapshotRequirementStatus).toBeDefined(); }); it('generates self-contained TypeScript declarations', async () => { @@ -59,9 +65,8 @@ describe('Persistent snapshots', () => { expect(declarations).not.toContain('JsHostFunction'); expect(declarations).not.toMatch(/\b\w+Wrapper\b/); - expect(declarations).toContain( - "setHostFunctionFilter(filter: 'all' | 'snapshotRequirements'): this" - ); + expect(declarations).not.toMatch(/^export type (\w+) = \1$/m); + expect(declarations).toContain('build(): Promise'); }); it('enriches reusable host-function configuration errors', async () => { @@ -70,9 +75,6 @@ describe('Persistent snapshots', () => { ); 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' }) @@ -141,6 +143,27 @@ describe('Persistent snapshots', () => { await expect(Snapshot.load('missing-snapshot-layout', 'missing')).rejects.toMatchObject({ code: 'ERR_INTERNAL', }); + await expect(Snapshot.checkedLoad('missing-snapshot-layout', '')).rejects.toMatchObject({ + code: 'ERR_INVALID_ARG', + }); + }); + + it('rejects restoring a snapshot into the other sandbox type', async () => { + const proto = await new SandboxBuilder().build(); + const sandbox = await proto.loadRuntime(); + sandbox.addHandler('handler', 'function handler(event) { return event; }'); + const jsSnapshot = await sandbox.snapshot(); + const loaded = await sandbox.getLoadedSandbox(); + const loadedSnapshot = await loaded.snapshot(); + + await expect(loaded.restore(jsSnapshot)).rejects.toMatchObject({ + code: 'ERR_INVALID_ARG', + }); + const unloaded = await loaded.unload(); + await expect(unloaded.restore(loadedSnapshot)).rejects.toMatchObject({ + code: 'ERR_INVALID_ARG', + }); + unloaded.dispose(); }); it('persists and restores a JSSandbox with handlers not yet registered in the guest', async () => { @@ -159,16 +182,13 @@ describe('Persistent snapshots', () => { ); 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 restoredSandbox = await SandboxBuilder.fromSnapshot(persisted).build(); + expect(restoredSandbox).toBeInstanceOf(JSSandbox); const loaded = await restoredSandbox.getLoadedSandbox(); await expect(loaded.callHandler('counter', {})).resolves.toEqual({ count: 1 }); @@ -193,13 +213,12 @@ describe('Persistent snapshots', () => { 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(); + const persisted = await Snapshot.checkedLoad(directory, 'application-ready'); + const restored = await SandboxBuilder.fromSnapshot(persisted).build(); + expect(restored).toBeInstanceOf(LoadedJSSandbox); await expect(restored.callHandler('counter', {})).resolves.toEqual({ count: 2 }); const unloaded = await restored.unload(); @@ -232,21 +251,14 @@ describe('Persistent snapshots', () => { 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( + const persisted = await Snapshot.checkedLoad(directory, 'host-functions'); + await expect(SandboxBuilder.fromSnapshot(persisted).build()).rejects.toThrow( /requires host module 'host:state'/ ); - const restorer = await new SandboxBuilder() + const restored = await SandboxBuilder.fromSnapshot(persisted) .setHostFunctionModules(stateHostFunctionModules) - .setHostFunctionFilter('snapshotRequirements') - .buildFromSnapshot(persisted); - const restored = await restorer.restoreLoadedSandbox(); + .build(); await expect(restored.callHandler('handler', {})).resolves.toEqual({ value: 1 }); await restored.dispose(); }); From 5da30af0d7e7a8101f42f171bf0fecf035d0d26d Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:35:46 -0700 Subject: [PATCH 7/9] Address snapshot review findings * Reject snapshot tags ending in .hljs-base. A user tag can no longer replace a LoadedJSSandbox baseline tag. * Record GUEST_ABI_VERSION in snapshot metadata and reject snapshots recorded with another version, before running the guest. * Ignore recorded host modules that have no functions when checking restore requirements. * with_host_function_modules and setHostFunctionModules take the modules directly. Each module still moves into one builder. * Register the host-function manifest in get_loaded_sandbox only after a restore replaced guest memory, saving a guest call per load and unload. * Deprecate ProtoJSSandbox::host_module, register, and set_module_loader, and the Node proto.hostModule() and proto.register(), in favor of SandboxBuilder. * Type Node host callbacks as (...args: any[]) so typed callbacks satisfy strictFunctionTypes. * Store staged script sources as plain strings in snapshot metadata. * Revert the unrelated .vscode auto-approve entries and source-map-js override, and stop exporting __napiBindingTarget from ESM. * Document the CPU vendor and hypervisor binding, the guest ABI check, the reserved tag suffix, the 1 MiB staged-source limit, and ERR_BUSY. * Test runtime config forwarding on restore, the reserved tag suffix, the 1 MiB staged-source limit, the guest ABI check, and empty recorded modules. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- .vscode/settings.json | 4 +- docs/persistent-snapshots.md | 35 +++--- src/hyperlight-js-common/src/lib.rs | 9 ++ .../examples/persistent_snapshot/main.rs | 4 +- src/hyperlight-js/src/sandbox/js_sandbox.rs | 12 ++- .../src/sandbox/proto_js_sandbox.rs | 11 ++ .../src/sandbox/sandbox_builder.rs | 102 +++++++++++------- src/hyperlight-js/src/sandbox/snapshot.rs | 75 ++++++++++--- src/hyperlight-js/src/script.rs | 11 -- src/hyperlight-js/tests/host_functions.rs | 32 +++--- src/hyperlight-js/tests/module_loader.rs | 1 + .../tests/persistent_snapshots.rs | 48 +++++++++ src/hyperlight-js/tests/user_modules.rs | 25 +++-- src/js-host-api/README.md | 40 +++---- src/js-host-api/examples/README.md | 2 +- src/js-host-api/examples/host-functions.js | 6 +- .../examples/persistent-snapshot.js | 4 +- src/js-host-api/lib.js | 22 +--- src/js-host-api/lib.mjs | 1 - src/js-host-api/package-lock.json | 4 +- src/js-host-api/package.json | 3 - src/js-host-api/src/lib.rs | 28 ++--- .../tests/persistent-snapshots.test.js | 20 ++-- 23 files changed, 308 insertions(+), 191 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index c242f114..6a3c4a69 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,8 +5,6 @@ "hyperlight.h": "c" }, "chat.tools.terminal.autoApprove": { - "just": true, - "gh": true, - "git rev-parse": true + "just": true } } \ No newline at end of file diff --git a/docs/persistent-snapshots.md b/docs/persistent-snapshots.md index cadf2024..bcec6eb8 100644 --- a/docs/persistent-snapshots.md +++ b/docs/persistent-snapshots.md @@ -85,18 +85,19 @@ 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` | +| Host-function module | `HostFunctionModule`s containing Rust closures or Node callbacks | Host process through `CallHostJsFunction` | Records the supplied module and function names, not implementations | Supply fresh modules 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. +Write one function that builds the modules, and call it for each fresh or +restored builder. A builder consumes its modules, so each call must create fresh +module objects, callbacks, and state. Create mutable state inside the function +to isolate it per sandbox. Capturing an external `Arc`, static, or JavaScript +variable shares that state between sandboxes, which breaks the Sandbox isolation +model. Rust: @@ -110,14 +111,14 @@ fn host_function_modules() -> Vec { } let fresh = SandboxBuilder::new() - .with_host_function_modules(host_function_modules) + .with_host_function_modules(host_function_modules()) .build()? .load_runtime()?; let snapshot = Snapshot::::checked_load("./snapshots", OciTag::new("with-host-functions")?)?; let restored = SandboxBuilder::from_snapshot(snapshot) - .with_host_function_modules(host_function_modules) + .with_host_function_modules(host_function_modules()) .build()?; ``` @@ -137,13 +138,13 @@ function hostFunctionModules() { } const freshProto = await new SandboxBuilder() - .setHostFunctionModules(hostFunctionModules) + .setHostFunctionModules(hostFunctionModules()) .build(); const fresh = await freshProto.loadRuntime(); const snapshot = await Snapshot.checkedLoad('./snapshots', 'with-host-functions'); const restored = await SandboxBuilder.fromSnapshot(snapshot) - .setHostFunctionModules(hostFunctionModules) + .setHostFunctionModules(hostFunctionModules()) .build(); ``` @@ -172,6 +173,8 @@ 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. +hyperlight limits that metadata to 1 MiB, so `save()` fails when the staged +sources exceed it. Load large sources into a `LoadedJSSandbox` before saving. Creating a `LoadedJSSandbox` transfers every embedded module source into the guest's module-loader map before handlers are registered. A loaded snapshot @@ -229,10 +232,13 @@ the same output routing is required. ## Compatibility and trust -- Snapshots are tied to their CPU architecture and hyperlight snapshot format. +- Snapshots are tied to their CPU architecture, CPU vendor, hypervisor, and + hyperlight snapshot format. For example, a KVM snapshot taken on an Intel host + does not load on AMD or under MSHV. - hyperlight-js metadata is schema-versioned and records the producing package - version for diagnostics. Unsupported metadata schema versions are rejected - before starting guest execution. + version for diagnostics. It also records the guest runtime ABI version. + Unsupported metadata schema or guest ABI 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. @@ -266,6 +272,9 @@ persisted `LoadedJSSandbox` uses two OCI tags: - `application-ready.hljs-base`: the runtime-ready baseline - `application-ready`: the loaded guest state +`save()` rejects user tags ending in `.hljs-base`, so a user tag cannot replace a +baseline. + 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: diff --git a/src/hyperlight-js-common/src/lib.rs b/src/hyperlight-js-common/src/lib.rs index c2a6c230..f695276f 100644 --- a/src/hyperlight-js-common/src/lib.rs +++ b/src/hyperlight-js-common/src/lib.rs @@ -77,6 +77,15 @@ pub const TAG_JSON_WITH_BINARIES: u8 = 0x02; /// Example: `{"__bin__": 0}` means "insert sidecar blob 0 here". pub const PLACEHOLDER_BIN: &str = "__bin__"; +/// Version of the guest functions the host calls, such as +/// `RegisterHostModules`, `register_module`, `register_handler`, and +/// `RunHandler`, and of the payload formats they exchange. +/// +/// Increment it whenever any of them changes incompatibly. Persisted snapshots +/// carry the guest runtime that produced them, and the host rejects snapshots +/// recorded with another version. +pub const GUEST_ABI_VERSION: u32 = 1; + // ── Error type ─────────────────────────────────────────────────────── /// Lightweight decoding error — `no_std`-compatible (no `anyhow`, no `std`). diff --git a/src/hyperlight-js/examples/persistent_snapshot/main.rs b/src/hyperlight-js/examples/persistent_snapshot/main.rs index d37ac115..1390cc11 100644 --- a/src/hyperlight-js/examples/persistent_snapshot/main.rs +++ b/src/hyperlight-js/examples/persistent_snapshot/main.rs @@ -45,7 +45,7 @@ fn main() -> hyperlight_js::Result<()> { fn write_snapshot(path: PathBuf) -> hyperlight_js::Result<()> { let mut sandbox = SandboxBuilder::new() - .with_host_function_modules(host_function_modules) + .with_host_function_modules(host_function_modules()) .build()? .load_runtime()?; sandbox.add_handler( @@ -76,7 +76,7 @@ fn read_snapshot(path: PathBuf) -> hyperlight_js::Result<()> { let snapshot = Snapshot::::checked_load(path, OciTag::new("application-ready")?)?; let mut loaded = SandboxBuilder::from_snapshot(snapshot) - .with_host_function_modules(host_function_modules) + .with_host_function_modules(host_function_modules()) .build()?; let result = loaded.handle_event("counter", "{}".to_owned(), None)?; println!("After restore: {result}"); diff --git a/src/hyperlight-js/src/sandbox/js_sandbox.rs b/src/hyperlight-js/src/sandbox/js_sandbox.rs index 12f37718..c308c271 100644 --- a/src/hyperlight-js/src/sandbox/js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/js_sandbox.rs @@ -84,6 +84,9 @@ pub struct JSSandbox { modules: HashMap, // Host functions selected by SandboxBuilder. host_functions: HostFunctionManifest, + // Whether guest memory already holds `host_functions`. Restores replace guest + // memory, so `get_loaded_sandbox` registers the manifest again after them. + guest_has_host_functions: bool, // Snapshot of state before any handlers are added. // This is used to restore state back to a neutral JSSandbox. snapshot: Arc, @@ -100,6 +103,7 @@ impl JSSandbox { handlers: HashMap::new(), modules: HashMap::new(), host_functions, + guest_has_host_functions: true, snapshot, _metric_guard: SandboxMetricsGuard::new(), }) @@ -112,12 +116,12 @@ impl JSSandbox { 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, + guest_has_host_functions: false, snapshot, _metric_guard: SandboxMetricsGuard::new(), }) @@ -133,6 +137,7 @@ impl JSSandbox { handlers: snapshot.metadata().handlers().collect(), modules: snapshot.metadata().modules().collect(), host_functions, + guest_has_host_functions: false, snapshot: snapshot.inner(), _metric_guard: SandboxMetricsGuard::new(), }) @@ -163,6 +168,7 @@ impl JSSandbox { snapshot .metadata() .validate_host_functions(&self.host_functions)?; + self.guest_has_host_functions = false; self.inner.restore(snapshot.inner())?; self.handlers = snapshot.metadata().handlers().collect(); self.modules = snapshot.metadata().modules().collect(); @@ -379,7 +385,9 @@ impl JSSandbox { // 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)?; + if !self.guest_has_host_functions { + 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 diff --git a/src/hyperlight-js/src/sandbox/proto_js_sandbox.rs b/src/hyperlight-js/src/sandbox/proto_js_sandbox.rs index 60bbe823..683d9fc5 100644 --- a/src/hyperlight-js/src/sandbox/proto_js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/proto_js_sandbox.rs @@ -82,6 +82,9 @@ impl ProtoJSSandbox { /// Install a custom file system for module resolution and loading. /// /// Enables JavaScript module imports using the provided ~FileSystem~ implementation. + #[deprecated( + note = "Use SandboxBuilder::with_module_loader, which also works when restoring snapshots" + )] #[instrument(err(Debug), skip_all, level=Level::INFO)] pub fn set_module_loader( mut self, @@ -131,6 +134,7 @@ impl ProtoJSSandbox { /// # Example /// /// ``` + /// # #![allow(deprecated)] /// use hyperlight_js::SandboxBuilder; /// /// // Create a proto sandbox and register a host function. @@ -143,6 +147,9 @@ impl ProtoJSSandbox { /// let js_sandbox = sbox.load_runtime()?; /// # Ok::<(), hyperlight_host::HyperlightError>(()) /// ``` + #[deprecated( + note = "Use SandboxBuilder::with_host_function_modules, which also works when restoring snapshots" + )] #[instrument(skip(self), level=Level::INFO)] pub fn host_module(&mut self, name: impl Into + Debug) -> &mut HostModule { self.host_modules.entry(name.into()).or_default() @@ -153,6 +160,9 @@ impl ProtoJSSandbox { /// /// Registering a function with the same `module` and `name` as an existing function /// overwrites the previous registration. + #[deprecated( + note = "Use SandboxBuilder::with_host_function_modules, which also works when restoring snapshots" + )] #[instrument(err(Debug), skip(self, func), level=Level::INFO)] pub fn register( &mut self, @@ -160,6 +170,7 @@ impl ProtoJSSandbox { name: impl Into + Debug, func: impl Function + Send + Sync + 'static, ) -> Result<()> { + #[allow(deprecated)] self.host_module(module).register(name, func); Ok(()) } diff --git a/src/hyperlight-js/src/sandbox/sandbox_builder.rs b/src/hyperlight-js/src/sandbox/sandbox_builder.rs index 57053dea..8138412b 100644 --- a/src/hyperlight-js/src/sandbox/sandbox_builder.rs +++ b/src/hyperlight-js/src/sandbox/sandbox_builder.rs @@ -126,31 +126,25 @@ impl SandboxBuilder { } } - /// Define the host-function modules for this sandbox. + /// Supply 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: + /// Each module moves into the builder, so one module cannot be supplied to + /// two builders. Build fresh modules for each sandbox, for example with a + /// function that returns them. Capturing an [`std::sync::Arc`] or static + /// mutable state in a callback shares that state between sandboxes. /// /// ```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]); + /// 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()); + pub fn with_host_function_modules( + mut self, + modules: impl IntoIterator, + ) -> Self { + self.host_function_modules.extend(modules); self } @@ -441,6 +435,8 @@ impl SandboxBuilder> { if !is_hypervisor_present() { return Err(HyperlightError::NoHypervisorFound()); } + let mut builder = self + .apply_runtime_config(HyperlightSandboxBuilder::from_snapshot(self.source.inner()))?; let snapshot = self.source; let host_modules = host_modules_by_name(self.host_function_modules)?; let available_host_functions = host_function_manifest(&host_modules); @@ -448,25 +444,6 @@ impl SandboxBuilder> { .metadata() .validate_host_functions(&available_host_functions)?; - let mut builder = HyperlightSandboxBuilder::from_snapshot(snapshot.inner()); - #[cfg(target_os = "linux")] - { - builder = builder.interrupt_vcpu_sigrtmin_offset( - self.base_config.get_interrupt_vcpu_sigrtmin_offset(), - )?; - } - #[cfg(any(kvm, mshv3))] - { - builder = builder.interrupt_retry_delay(self.base_config.get_interrupt_retry_delay()); - } - #[cfg(crashdump)] - if let Some(enabled) = self.guest_core_dump { - builder = builder.guest_core_dump(enabled); - } - #[cfg(gdb)] - if let Some(debug_info) = self.guest_debug_info { - builder = builder.guest_debug_info(debug_info); - } if let Some(host_print_fn) = self.host_print_fn { builder = builder.host_print(host_print_fn); } @@ -494,8 +471,59 @@ impl SandboxBuilder> { } } +impl SandboxBuilder { + /// Apply settings that the snapshot layout does not fix. + fn apply_runtime_config( + &self, + mut builder: HyperlightSandboxBuilder, + ) -> Result { + #[cfg(target_os = "linux")] + { + builder = builder.interrupt_vcpu_sigrtmin_offset( + self.base_config.get_interrupt_vcpu_sigrtmin_offset(), + )?; + } + #[cfg(any(kvm, mshv3))] + { + builder = builder.interrupt_retry_delay(self.base_config.get_interrupt_retry_delay()); + } + #[cfg(crashdump)] + if let Some(enabled) = self.guest_core_dump { + builder = builder.guest_core_dump(enabled); + } + #[cfg(gdb)] + if let Some(debug_info) = self.guest_debug_info { + builder = builder.guest_debug_info(debug_info); + } + Ok(builder) + } +} + impl Default for SandboxBuilder { fn default() -> Self { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(all(target_os = "linux", any(kvm, mshv3)))] + fn runtime_config_reaches_hyperlight_builder() { + let mut builder = + SandboxBuilder::new().with_interrupt_retry_delay(Duration::from_millis(7)); + builder.set_interrupt_vcpu_sigrtmin_offset(3).unwrap(); + + let hyperlight = builder + .apply_runtime_config(HyperlightSandboxBuilder::from_bytes(Vec::new())) + .unwrap(); + + assert_eq!(hyperlight.get_interrupt_vcpu_sigrtmin_offset(), 3); + assert_eq!( + hyperlight.get_interrupt_retry_delay(), + Duration::from_millis(7) + ); + } +} diff --git a/src/hyperlight-js/src/sandbox/snapshot.rs b/src/hyperlight-js/src/sandbox/snapshot.rs index af7184f4..71c43c86 100644 --- a/src/hyperlight-js/src/sandbox/snapshot.rs +++ b/src/hyperlight-js/src/sandbox/snapshot.rs @@ -23,6 +23,7 @@ use hyperlight_host::sandbox::snapshot::{ OciDigest, OciReference, OciTag, Snapshot as HyperlightSnapshot, }; use hyperlight_host::Sandbox; +use hyperlight_js_common::GUEST_ABI_VERSION; use serde::{Deserialize, Serialize}; use super::js_sandbox::{validate_module_identifier, validate_namespace_not_reserved}; @@ -57,14 +58,14 @@ pub enum SnapshotKind { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub(crate) struct ScriptMetadata { - content: Arc, + content: String, base_path: Option, } impl From<&Script> for ScriptMetadata { fn from(script: &Script) -> Self { Self { - content: script.shared_content(), + content: script.content().to_owned(), base_path: script.base_path().map(Path::to_path_buf), } } @@ -72,7 +73,7 @@ impl From<&Script> for ScriptMetadata { impl From for Script { fn from(metadata: ScriptMetadata) -> Self { - let script = Script::from_shared_content(metadata.content); + let script = Script::from_content(metadata.content); match metadata.base_path { Some(path) => script.with_virtual_base(path.to_string_lossy()), None => script, @@ -85,6 +86,7 @@ impl From for Script { pub(crate) struct SnapshotMetadata { schema_version: u32, hyperlight_js_version: String, + guest_abi_version: u32, kind: SnapshotKind, handlers: BTreeMap, modules: BTreeMap, @@ -104,6 +106,7 @@ impl SnapshotMetadata { Self { schema_version: METADATA_SCHEMA_VERSION, hyperlight_js_version: env!("CARGO_PKG_VERSION").to_owned(), + guest_abi_version: GUEST_ABI_VERSION, kind, handlers: handlers.collect(), modules: modules.collect(), @@ -135,7 +138,12 @@ impl SnapshotMetadata { /// `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 { + // A module with no functions gives the guest nothing to call. + let required = self + .host_functions + .iter() + .filter(|(_, functions)| !functions.is_empty()); + for (module, required_functions) in required { let available_functions = available.get(module).ok_or_else(|| { new_error!( "Snapshot requires host module '{}', but it was not registered", @@ -164,6 +172,14 @@ impl SnapshotMetadata { METADATA_SCHEMA_VERSION )); } + if self.guest_abi_version != GUEST_ABI_VERSION { + return Err(new_error!( + "Snapshot guest runtime ABI version {} produced by hyperlight-js {} is incompatible with ABI version {}", + self.guest_abi_version, + self.hyperlight_js_version, + GUEST_ABI_VERSION + )); + } match (self.kind, self.base_snapshot.is_some()) { (SnapshotKind::JsSandbox, true) => { return Err(new_error!( @@ -423,8 +439,10 @@ impl Snapshot { /// Save the snapshot to an OCI image layout and return its manifest digest. /// /// A [`LoadedJSSandbox`] snapshot also saves its runtime-ready baseline under - /// `.hljs-base`, which limits `tag` to 118 bytes. + /// `.hljs-base`, which limits `tag` to 118 bytes. Tags ending in + /// `.hljs-base` are reserved for these baselines. pub fn save(&self, path: impl AsRef, tag: &OciTag) -> Result { + validate_user_tag(tag)?; 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 @@ -452,6 +470,16 @@ impl Snapshot { } } +fn validate_user_tag(tag: &OciTag) -> Result<()> { + if tag.as_str().ends_with(BASE_TAG_SUFFIX) { + return Err(new_error!( + "Snapshot tag '{}' must not end in '{BASE_TAG_SUFFIX}', which is reserved for LoadedJSSandbox baselines", + tag.as_str() + )); + } + Ok(()) +} + 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. @@ -515,6 +543,16 @@ mod tests { .contains("produced by hyperlight-js 9.8.7")); } + #[test] + fn metadata_rejects_other_guest_abi_version() { + let mut metadata = metadata(SnapshotKind::JsSandbox); + metadata.guest_abi_version = GUEST_ABI_VERSION + 1; + + let error = metadata.validate().unwrap_err(); + + assert!(error.to_string().contains("guest runtime ABI version")); + } + #[test] fn loaded_metadata_does_not_require_staged_definitions() { let mut metadata = metadata(SnapshotKind::LoadedJsSandbox); @@ -595,26 +633,31 @@ mod tests { } #[test] - fn script_metadata_shares_source_and_preserves_json_shape() { + fn host_function_validation_ignores_recorded_empty_modules() { + let mut metadata = metadata(SnapshotKind::LoadedJsSandbox); + metadata + .host_functions + .insert("host:empty".to_owned(), Vec::new()); + + metadata + .validate_host_functions(&HostFunctionManifest::new()) + .unwrap(); + } + + #[test] + fn script_metadata_round_trips_source_and_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(); + let restored_script: Script = restored_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;" - ); + assert_eq!(restored_script.content(), "export const answer = 42;"); + assert_eq!(restored_script.base_path(), Some(Path::new("/modules"))); } #[test] diff --git a/src/hyperlight-js/src/script.rs b/src/hyperlight-js/src/script.rs index 8883cd5f..8a1802ee 100644 --- a/src/hyperlight-js/src/script.rs +++ b/src/hyperlight-js/src/script.rs @@ -74,17 +74,6 @@ impl Script { Arc::unwrap_or_clone(self.content).into_bytes() } - 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 8f490f0e..b2a71c0a 100644 --- a/src/hyperlight-js/tests/host_functions.rs +++ b/src/hyperlight-js/tests/host_functions.rs @@ -16,6 +16,8 @@ limitations under the License. //! Test for host modules / functions. #![allow(clippy::disallowed_macros)] +// Most tests here cover the deprecated ProtoJSSandbox registration API. +#![allow(deprecated)] use hyperlight_js::{HostFunctionModule, LoadedJSSandbox, SandboxBuilder, Script, Snapshot}; @@ -1138,7 +1140,7 @@ fn extra_host_function_module() -> HostFunctionModule { fn loaded_snapshot_requiring_host_function() -> Snapshot { let mut source = SandboxBuilder::new() - .with_host_function_modules(|| [required_host_function_module()]) + .with_host_function_modules([required_host_function_module()]) .build() .unwrap() .load_runtime() @@ -1161,25 +1163,23 @@ fn loaded_snapshot_requiring_host_function() -> Snapshot { fn loaded_restore_keeps_captured_requirements_until_unload() { let snapshot = loaded_snapshot_requiring_host_function(); let mut restored = SandboxBuilder::from_snapshot(snapshot) - .with_host_function_modules(|| { - [ - required_host_function_module(), - extra_host_function_module(), - ] - }) + .with_host_function_modules([ + required_host_function_module(), + extra_host_function_module(), + ]) .build() .unwrap(); let loaded_snapshot = restored.snapshot().unwrap(); SandboxBuilder::from_snapshot(loaded_snapshot) - .with_host_function_modules(|| [required_host_function_module()]) + .with_host_function_modules([required_host_function_module()]) .build() .unwrap(); let mut unloaded = restored.unload().unwrap(); let unloaded_snapshot = unloaded.snapshot().unwrap(); let error = match SandboxBuilder::from_snapshot(unloaded_snapshot) - .with_host_function_modules(|| [required_host_function_module()]) + .with_host_function_modules([required_host_function_module()]) .build() { Ok(_) => panic!("restore without the extra module unexpectedly succeeded"), @@ -1208,7 +1208,7 @@ fn loaded_restore_keeps_captured_requirements_until_unload() { #[test] fn restored_js_sandbox_records_supplied_host_functions() { let source_proto = SandboxBuilder::new() - .with_host_function_modules(|| [required_host_function_module()]) + .with_host_function_modules([required_host_function_module()]) .build() .unwrap(); let mut source = source_proto.load_runtime().unwrap(); @@ -1226,18 +1226,16 @@ fn restored_js_sandbox_records_supplied_host_functions() { let snapshot = source.snapshot().unwrap(); let mut restored = SandboxBuilder::from_snapshot(snapshot) - .with_host_function_modules(|| { - [ - required_host_function_module(), - extra_host_function_module(), - ] - }) + .with_host_function_modules([ + required_host_function_module(), + extra_host_function_module(), + ]) .build() .unwrap(); let complete_snapshot = restored.snapshot().unwrap(); let mut insufficient = SandboxBuilder::new() - .with_host_function_modules(|| [required_host_function_module()]) + .with_host_function_modules([required_host_function_module()]) .build() .unwrap() .load_runtime() diff --git a/src/hyperlight-js/tests/module_loader.rs b/src/hyperlight-js/tests/module_loader.rs index 93174c12..a3d44cc4 100644 --- a/src/hyperlight-js/tests/module_loader.rs +++ b/src/hyperlight-js/tests/module_loader.rs @@ -139,6 +139,7 @@ fn test_handler_import_restrictions() { "#; // Compatibility coverage for callers that still configure a fresh ProtoJSSandbox. + #[allow(deprecated)] let proto_js_sandbox = SandboxBuilder::new() .build() .unwrap() diff --git a/src/hyperlight-js/tests/persistent_snapshots.rs b/src/hyperlight-js/tests/persistent_snapshots.rs index 3b686518..0cc8b85c 100644 --- a/src/hyperlight-js/tests/persistent_snapshots.rs +++ b/src/hyperlight-js/tests/persistent_snapshots.rs @@ -262,6 +262,54 @@ fn loaded_snapshot_rejects_tag_that_would_collide_after_truncation() { assert!(!directory.path().join("index.json").exists()); } +#[test] +fn js_sandbox_snapshot_rejects_staged_sources_over_config_limit() { + let directory = tempfile::tempdir().unwrap(); + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + let padding = "x".repeat(1024 * 1024); + sandbox + .add_handler( + "large", + Script::from_content(format!( + "const padding = '{padding}';\nfunction handler(event) {{ return event; }}" + )), + ) + .unwrap(); + let snapshot = sandbox.snapshot().unwrap(); + + let error = snapshot + .save(directory.path(), &oci_tag("large")) + .unwrap_err(); + + assert!(error.to_string().contains("config blob")); + assert!(!directory.path().join("index.json").exists()); +} + +#[test] +fn snapshots_reject_reserved_baseline_tag_suffix() { + let directory = tempfile::tempdir().unwrap(); + let mut sandbox = SandboxBuilder::new() + .build() + .unwrap() + .load_runtime() + .unwrap(); + sandbox.add_handler("counter", counter_handler()).unwrap(); + let js_snapshot = sandbox.snapshot().unwrap(); + let loaded_snapshot = sandbox.get_loaded_sandbox().unwrap().snapshot().unwrap(); + let tag = oci_tag("application.hljs-base"); + + let js_error = js_snapshot.save(directory.path(), &tag).unwrap_err(); + let loaded_error = loaded_snapshot.save(directory.path(), &tag).unwrap_err(); + + assert!(js_error.to_string().contains("reserved")); + assert!(loaded_error.to_string().contains("reserved")); + assert!(!directory.path().join("index.json").exists()); +} + #[test] fn persisted_js_sandbox_restores_embedded_javascript_modules() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/hyperlight-js/tests/user_modules.rs b/src/hyperlight-js/tests/user_modules.rs index f0b02189..544f3a78 100644 --- a/src/hyperlight-js/tests/user_modules.rs +++ b/src/hyperlight-js/tests/user_modules.rs @@ -18,7 +18,7 @@ limitations under the License. //! These tests exercise the full lifecycle: host-side registration → guest-side //! lazy compilation → handler import → execution. -use hyperlight_js::{SandboxBuilder, Script}; +use hyperlight_js::{HostFunctionModule, SandboxBuilder, Script}; // ── Basic import ───────────────────────────────────────────────────── @@ -523,13 +523,14 @@ fn user_module_can_import_host_function() { "#, ); - let mut proto = SandboxBuilder::new().build().unwrap(); - proto - .register( - "db", - "lookup", - |id: i32| serde_json::json!({ "id": id, "name": format!("User {}", id) }), - ) + let mut db = HostFunctionModule::new("db"); + db.register( + "lookup", + |id: i32| serde_json::json!({ "id": id, "name": format!("User {}", id) }), + ); + let proto = SandboxBuilder::new() + .with_host_function_modules([db]) + .build() .unwrap(); let mut sandbox = proto.load_runtime().unwrap(); @@ -834,11 +835,9 @@ fn external_text_payloads_preserve_capacity_across_buffer_sizes() { assert_eq!(config.get_h2g_pool_pages(), input_pages); assert_eq!(config.get_g2h_pool_pages(), output_pages); - let mut proto = builder.build().unwrap(); - - proto - .register("host", "echo", |value: String| value) - .unwrap(); + let mut host = HostFunctionModule::new("host"); + host.register("echo", |value: String| value); + let proto = builder.with_host_function_modules([host]).build().unwrap(); let mut sandbox = proto.load_runtime().unwrap(); sandbox diff --git a/src/js-host-api/README.md b/src/js-host-api/README.md index 5e0e462e..73561289 100644 --- a/src/js-host-api/README.md +++ b/src/js-host-api/README.md @@ -59,7 +59,7 @@ Creates and configures a new sandbox. - `setOutputTransportBufferSize(bytes: number)` → `this` — Set the capacity of each large guest-to-host transport buffer (must be > 0, chainable) - `setInputBufferSize(bytes: number)` → `this` — **Deprecated:** set legacy input/host-reply capacity in bytes; use `setInputTransportPoolPages` and `setOutputTransportPoolPages` for explicit pool budgets - `setOutputBufferSize(bytes: number)` → `this` — **Deprecated:** set legacy output/host-request capacity in bytes; use `setOutputTransportPoolPages` -- `setHostFunctionModules(factory: () => HostFunctionModule[])` → `this` — Define fresh host callbacks for one sandbox +- `setHostFunctionModules(modules: HostFunctionModule[])` → `this` — Supply host callbacks for one sandbox. Each module is consumed - `build()` → `Promise` — Builds a proto sandbox ready to load the JavaScript runtime **Static methods:** @@ -83,7 +83,7 @@ Configure host functions on `SandboxBuilder` before calling `build()`. **Methods:** - `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. +- `hostModule()` / `register()` — **Deprecated:** use `SandboxBuilder.setHostFunctionModules()`, which also works when restoring snapshots. ```javascript function hostFunctionModules() { @@ -92,7 +92,7 @@ function hostFunctionModules() { return [math]; } -const proto = await new SandboxBuilder().setHostFunctionModules(hostFunctionModules).build(); +const proto = await new SandboxBuilder().setHostFunctionModules(hostFunctionModules()).build(); const jsSandbox = await proto.loadRuntime(); ``` @@ -320,7 +320,7 @@ await snapshot.save('./snapshots', 'application-ready'); // Later, potentially in another process. const persisted = await Snapshot.checkedLoad('./snapshots', 'application-ready'); const restored = await SandboxBuilder.fromSnapshot(persisted) - .setHostFunctionModules(hostFunctionModules) + .setHostFunctionModules(hostFunctionModules()) .build(); ``` @@ -329,7 +329,7 @@ const restored = await SandboxBuilder.fromSnapshot(persisted) The same workflow restores a `JSSandbox` snapshot taken before handlers are loaded. Native callbacks are not serialized, so the restoring builder must -receive a factory that creates compatible `HostFunctionModule`s. Restoring a +receive fresh, compatible `HostFunctionModule`s. Restoring a snapshot of the other sandbox type into an existing sandbox fails with `ERR_INVALID_ARG`. See [Persistent snapshots](../../docs/persistent-snapshots.md). @@ -345,6 +345,7 @@ All errors thrown by the API include a `code` property for programmatic handling | `ERR_POISONED` | Sandbox is in an inconsistent state (after timeout kill, guest abort, stack overflow, etc.) — restore from snapshot or unload | | `ERR_CANCELLED` | Execution was cancelled (by monitor timeout or manual `kill()`) | | `ERR_GUEST_ABORT` | Guest code aborted | +| `ERR_BUSY` | A `JSSandbox` method was called while its `snapshot()` or `restore()` was still running | | `ERR_INTERNAL` | Unexpected internal error | ```javascript @@ -379,9 +380,8 @@ sequenceDiagram participant Host as Host Callback (Node.js) Note over Guest,Host: Builder configuration - Host->>Bridge: builder.setHostFunctionModules(hostFunctionModules) - Bridge->>Bridge: Invoke factory once - Bridge->>HL: Installs selected callback closures + Host->>Bridge: builder.setHostFunctionModules(hostFunctionModules()) + Bridge->>HL: Installs callback closures Note over Guest,Host: Invocation (during callHandler) Guest->>HL: math.add(1, 2) @@ -393,7 +393,7 @@ sequenceDiagram HL-->>Guest: 3 ``` -1. **Define** a reusable factory that creates fresh `HostFunctionModule` objects and supply it to `SandboxBuilder` +1. **Define** a function that creates fresh `HostFunctionModule` objects and supply its result 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 @@ -408,7 +408,7 @@ function hostFunctionModules() { return [math]; } -const proto = await new SandboxBuilder().setHostFunctionModules(hostFunctionModules).build(); +const proto = await new SandboxBuilder().setHostFunctionModules(hostFunctionModules()).build(); const sandbox = await proto.loadRuntime(); // Guest code can now call math.add() @@ -486,11 +486,11 @@ math.register('multiply', (a, b) => a * b); Throws `ERR_INVALID_ARG` if function name is empty. -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. +Supply modules with `builder.setHostFunctionModules(hostFunctionModules())`. +The builder consumes them. Call `hostFunctionModules()` again for each fresh or +restored builder. Create mutable state inside the function to isolate it per +sandbox. State captured from an outer scope is shared between every sandbox +using those modules. ### Async Callbacks @@ -560,7 +560,7 @@ function hostFunctionModules() { } const proto = await new SandboxBuilder() - .setHostFunctionModules(hostFunctionModules) + .setHostFunctionModules(hostFunctionModules()) .build(); const sandbox = await proto.loadRuntime(); sandbox.addHandler('handler', ` @@ -638,7 +638,7 @@ function hostFunctionModules() { } const proto = await new SandboxBuilder() - .setHostFunctionModules(hostFunctionModules) + .setHostFunctionModules(hostFunctionModules()) .build(); const sandbox = await proto.loadRuntime(); ``` @@ -647,11 +647,11 @@ const sandbox = await proto.loadRuntime(); 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. +supply fresh modules to the restoring builder as shown above. ```javascript function hostFunctionModules() { - // This counter belongs to one sandbox because it is created inside the factory. + // This counter belongs to one sandbox because each call creates it. let callCount = 0; const stats = new HostFunctionModule('stats'); stats.register('hit', () => { @@ -661,7 +661,7 @@ function hostFunctionModules() { return [stats]; } -const proto = await new SandboxBuilder().setHostFunctionModules(hostFunctionModules).build(); +const proto = await new SandboxBuilder().setHostFunctionModules(hostFunctionModules()).build(); const sandbox = await proto.loadRuntime(); sandbox.addHandler('handler', ` import * as stats from "host:stats"; diff --git a/src/js-host-api/examples/README.md b/src/js-host-api/examples/README.md index 4afd9082..d526357c 100644 --- a/src/js-host-api/examples/README.md +++ b/src/js-host-api/examples/README.md @@ -119,7 +119,7 @@ Testing calculator operations: ### Host Functions -Defines a reusable factory that creates fresh `HostFunctionModule` objects for +Defines a function that creates fresh `HostFunctionModule` objects for each `SandboxBuilder` before loading the guest runtime: ```bash diff --git a/src/js-host-api/examples/host-functions.js b/src/js-host-api/examples/host-functions.js index 03136022..eda8dddb 100644 --- a/src/js-host-api/examples/host-functions.js +++ b/src/js-host-api/examples/host-functions.js @@ -38,12 +38,12 @@ async function main() { 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. + // Call hostFunctionModules() again for another builder. Each call creates + // fresh module objects, callbacks, and captured state. const proto = await new SandboxBuilder() .setHeapSize(8 * 1024 * 1024) .setScratchSize(1024 * 1024) - .setHostFunctionModules(hostFunctionModules) + .setHostFunctionModules(hostFunctionModules()) .build(); // ── Load runtime and add a handler ─────────────────────────────── diff --git a/src/js-host-api/examples/persistent-snapshot.js b/src/js-host-api/examples/persistent-snapshot.js index 801b89ec..42f517bf 100644 --- a/src/js-host-api/examples/persistent-snapshot.js +++ b/src/js-host-api/examples/persistent-snapshot.js @@ -26,7 +26,7 @@ function hostFunctionModules() { } async function writeSnapshot() { - const proto = await new SandboxBuilder().setHostFunctionModules(hostFunctionModules).build(); + const proto = await new SandboxBuilder().setHostFunctionModules(hostFunctionModules()).build(); const sandbox = await proto.loadRuntime(); sandbox.addHandler( 'counter', @@ -52,7 +52,7 @@ async function writeSnapshot() { async function readSnapshot() { const snapshot = await Snapshot.checkedLoad(path, 'application-ready'); const loaded = await SandboxBuilder.fromSnapshot(snapshot) - .setHostFunctionModules(hostFunctionModules) + .setHostFunctionModules(hostFunctionModules()) .build(); console.log('After restore:', await loaded.callHandler('counter', {})); await loaded.dispose(); diff --git a/src/js-host-api/lib.js b/src/js-host-api/lib.js index 669eae83..68da226b 100644 --- a/src/js-host-api/lib.js +++ b/src/js-host-api/lib.js @@ -295,33 +295,13 @@ for (const method of [ 'setOutputTransportBufferSize', 'setInputBufferSize', 'setOutputBufferSize', + 'setHostFunctionModules', ]) { 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 73aa4381..291f7f4c 100644 --- a/src/js-host-api/lib.mjs +++ b/src/js-host-api/lib.mjs @@ -21,7 +21,6 @@ limitations under the License. import native from './lib.js'; export const { - __napiBindingTarget, HostFunctionModule, HostModule, InterruptHandle, diff --git a/src/js-host-api/package-lock.json b/src/js-host-api/package-lock.json index 87fd704c..a7da8907 100644 --- a/src/js-host-api/package-lock.json +++ b/src/js-host-api/package-lock.json @@ -3723,8 +3723,8 @@ }, "node_modules/source-map-js": { "version": "1.2.2", - "resolved": "https://github.com/7rulnik/source-map-js/archive/0a1d334fd1e55a47df97fcd60a7915d46df3b08a.tar.gz", - "integrity": "sha512-LyZ0y1bc1EaaGZYRIyWHwk64orB1T9Nrg7S05Y1oiExuGcGjWXioof+9xF7/sVCAulUyH+T743F0Zi9mNlf6TA==", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "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 f2cec851..04174180 100644 --- a/src/js-host-api/package.json +++ b/src/js-host-api/package.json @@ -50,9 +50,6 @@ "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" }, diff --git a/src/js-host-api/src/lib.rs b/src/js-host-api/src/lib.rs index fe726c2b..94203f37 100644 --- a/src/js-host-api/src/lib.rs +++ b/src/js-host-api/src/lib.rs @@ -633,33 +633,30 @@ impl SandboxBuilderWrapper { /// 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. + /// Build fresh modules for each sandbox, for example with a function that + /// returns them. #[napi] pub fn set_host_function_modules( &self, - #[napi(ts_arg_type = "() => Array")] factory: Vec< - ClassInstance<'_, HostFunctionModuleWrapper>, - >, + modules: Vec>, ) -> 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] + for (index, module) in modules.iter().enumerate() { + if modules[..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", + "Host-function modules contain the same module object more than once", )); } } - let mut module_guards = factory + let mut module_guards = modules .iter() .map(|module| module.inner.lock().map_err(|_| lock_error())) .collect::>>()?; @@ -667,7 +664,7 @@ impl SandboxBuilderWrapper { return Err(consumed_error("HostFunctionModule")); } - let modules = module_guards + let taken = module_guards .iter_mut() .map(|module| { module @@ -680,7 +677,7 @@ impl SandboxBuilderWrapper { .ok_or_else(|| consumed_error("SandboxBuilder"))?; *builder_guard = Some(map_builder!( builder, - b => b.with_host_function_modules(|| modules) + b => b.with_host_function_modules(taken) )); Ok(self) } @@ -949,6 +946,7 @@ impl ProtoJSSandboxWrapper { /// @param name - Module name that guest JS uses in `import * as name from "host:name"` /// @returns A `HostModule` for registering functions /// @throws If the module name is empty + /// @deprecated Use `SandboxBuilder.setHostFunctionModules()`, which also works when restoring snapshots. #[napi] pub fn host_module(&self, name: String) -> napi::Result { validate_module_name(&name)?; @@ -971,6 +969,7 @@ impl ProtoJSSandboxWrapper { /// @param functionName - Function name within the module /// @param callback - `(...args) => any | Promise` — the host function implementation /// @throws If module name or function name is empty + /// @deprecated Use `SandboxBuilder.setHostFunctionModules()`, which also works when restoring snapshots. #[napi] #[allow(clippy::type_complexity)] // allow the type complexity here so that index.d.ts is cleaner pub fn register( @@ -1714,7 +1713,7 @@ impl HostFunctionModuleWrapper { pub fn register( &self, name: String, - #[napi(ts_arg_type = "(...args: unknown[]) => unknown | Promise")] + #[napi(ts_arg_type = "(...args: any[]) => unknown | Promise")] func: JsHostFunction, ) -> napi::Result<&Self> { if name.is_empty() { @@ -1809,7 +1808,7 @@ impl HostModuleWrapper { pub fn register( &self, name: String, - #[napi(ts_arg_type = "(...args: unknown[]) => unknown | Promise")] + #[napi(ts_arg_type = "(...args: any[]) => unknown | Promise")] func: JsHostFunction, ) -> napi::Result<()> { if name.is_empty() { @@ -1818,6 +1817,7 @@ impl HostModuleWrapper { let wrapper = js_host_function(func); self.sandbox.with_inner_mut(|sandbox| { + #[allow(deprecated)] sandbox .host_module(&self.module_name) .register_js(name, wrapper); diff --git a/src/js-host-api/tests/persistent-snapshots.test.js b/src/js-host-api/tests/persistent-snapshots.test.js index a32b6813..fb87cf4c 100644 --- a/src/js-host-api/tests/persistent-snapshots.test.js +++ b/src/js-host-api/tests/persistent-snapshots.test.js @@ -76,7 +76,7 @@ describe('Persistent snapshots', () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - expect(() => builder.setHostFunctionModules(() => [])).toThrow( + expect(() => builder.setHostFunctionModules([])).toThrow( expect.objectContaining({ code: 'ERR_CONSUMED' }) ); const sandbox = await proto.loadRuntime(); @@ -85,7 +85,7 @@ describe('Persistent snapshots', () => { it('prevents sharing a host-function module across sandbox builders', () => { const [state] = stateHostFunctionModules(); - const modules = () => [state]; + const modules = [state]; new SandboxBuilder().setHostFunctionModules(modules); @@ -102,10 +102,10 @@ describe('Persistent snapshots', () => { available.register('value', () => 1); const consumed = new HostFunctionModule('consumed'); consumed.register('value', () => 2); - new SandboxBuilder().setHostFunctionModules(() => [consumed]); + new SandboxBuilder().setHostFunctionModules([consumed]); const builder = new SandboxBuilder(); - expect(() => builder.setHostFunctionModules(() => [available, consumed])).toThrow( + expect(() => builder.setHostFunctionModules([available, consumed])).toThrow( expect.objectContaining({ code: 'ERR_CONSUMED' }) ); @@ -117,9 +117,9 @@ describe('Persistent snapshots', () => { const duplicate = new HostFunctionModule('duplicate'); duplicate.register('value', () => 1); - expect(() => - new SandboxBuilder().setHostFunctionModules(() => [duplicate, duplicate]) - ).toThrow(expect.objectContaining({ code: 'ERR_INVALID_ARG' })); + expect(() => new SandboxBuilder().setHostFunctionModules([duplicate, duplicate])).toThrow( + expect.objectContaining({ code: 'ERR_INVALID_ARG' }) + ); duplicate.register('stillAvailable', () => true); }); @@ -130,7 +130,7 @@ describe('Persistent snapshots', () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - expect(() => builder.setHostFunctionModules(() => [available])).toThrow( + expect(() => builder.setHostFunctionModules([available])).toThrow( expect.objectContaining({ code: 'ERR_CONSUMED' }) ); available.register('stillAvailable', () => true); @@ -233,7 +233,7 @@ describe('Persistent snapshots', () => { it('requires host functions used by the persisted sandbox', async () => { const directory = await snapshotDirectory(); const proto = await new SandboxBuilder() - .setHostFunctionModules(stateHostFunctionModules) + .setHostFunctionModules(stateHostFunctionModules()) .build(); const sandbox = await proto.loadRuntime(); sandbox.addHandler( @@ -257,7 +257,7 @@ describe('Persistent snapshots', () => { ); const restored = await SandboxBuilder.fromSnapshot(persisted) - .setHostFunctionModules(stateHostFunctionModules) + .setHostFunctionModules(stateHostFunctionModules()) .build(); await expect(restored.callHandler('handler', {})).resolves.toEqual({ value: 1 }); await restored.dispose(); From 106f58ed4d873fd86a6a749be12776c0f905ef26 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:46:46 -0700 Subject: [PATCH 8/9] Fix snapshot builder warnings on macOS and Windows apply_runtime_config took `mut builder` but reassigned it only under Linux and debug cfgs, so non-Linux builds hit the crate's unused_mut deny. Shadow the builder per cfg instead. Gate the builder test module on kvm or mshv3, since its only test needs both Linux interrupt settings. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- .../src/sandbox/sandbox_builder.rs | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/hyperlight-js/src/sandbox/sandbox_builder.rs b/src/hyperlight-js/src/sandbox/sandbox_builder.rs index 8138412b..d57ca096 100644 --- a/src/hyperlight-js/src/sandbox/sandbox_builder.rs +++ b/src/hyperlight-js/src/sandbox/sandbox_builder.rs @@ -475,26 +475,24 @@ impl SandboxBuilder { /// Apply settings that the snapshot layout does not fix. fn apply_runtime_config( &self, - mut builder: HyperlightSandboxBuilder, + builder: HyperlightSandboxBuilder, ) -> Result { #[cfg(target_os = "linux")] - { - builder = builder.interrupt_vcpu_sigrtmin_offset( - self.base_config.get_interrupt_vcpu_sigrtmin_offset(), - )?; - } + let builder = builder.interrupt_vcpu_sigrtmin_offset( + self.base_config.get_interrupt_vcpu_sigrtmin_offset(), + )?; #[cfg(any(kvm, mshv3))] - { - builder = builder.interrupt_retry_delay(self.base_config.get_interrupt_retry_delay()); - } + let builder = builder.interrupt_retry_delay(self.base_config.get_interrupt_retry_delay()); #[cfg(crashdump)] - if let Some(enabled) = self.guest_core_dump { - builder = builder.guest_core_dump(enabled); - } + let builder = match self.guest_core_dump { + Some(enabled) => builder.guest_core_dump(enabled), + None => builder, + }; #[cfg(gdb)] - if let Some(debug_info) = self.guest_debug_info { - builder = builder.guest_debug_info(debug_info); - } + let builder = match self.guest_debug_info { + Some(debug_info) => builder.guest_debug_info(debug_info), + None => builder, + }; Ok(builder) } } @@ -505,12 +503,11 @@ impl Default for SandboxBuilder { } } -#[cfg(test)] +#[cfg(all(test, any(kvm, mshv3)))] mod tests { use super::*; #[test] - #[cfg(all(target_os = "linux", any(kvm, mshv3)))] fn runtime_config_reaches_hyperlight_builder() { let mut builder = SandboxBuilder::new().with_interrupt_retry_delay(Duration::from_millis(7)); From dd4384bf4e3a48d1269e82329054ca4c748ee1c8 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:08:07 -0700 Subject: [PATCH 9/9] Always register host functions before loading handlers Restore the unconditional RegisterHostModules call in get_loaded_sandbox and unload. Skipping it for freshly built sandboxes made the CPU-time monitor tests fail on macOS debug CI, where they passed before that change. This keeps the fresh-sandbox call sequence identical to the previous release. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- src/hyperlight-js/src/sandbox/js_sandbox.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/hyperlight-js/src/sandbox/js_sandbox.rs b/src/hyperlight-js/src/sandbox/js_sandbox.rs index c308c271..12f37718 100644 --- a/src/hyperlight-js/src/sandbox/js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/js_sandbox.rs @@ -84,9 +84,6 @@ pub struct JSSandbox { modules: HashMap, // Host functions selected by SandboxBuilder. host_functions: HostFunctionManifest, - // Whether guest memory already holds `host_functions`. Restores replace guest - // memory, so `get_loaded_sandbox` registers the manifest again after them. - guest_has_host_functions: bool, // Snapshot of state before any handlers are added. // This is used to restore state back to a neutral JSSandbox. snapshot: Arc, @@ -103,7 +100,6 @@ impl JSSandbox { handlers: HashMap::new(), modules: HashMap::new(), host_functions, - guest_has_host_functions: true, snapshot, _metric_guard: SandboxMetricsGuard::new(), }) @@ -116,12 +112,12 @@ impl JSSandbox { 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, - guest_has_host_functions: false, snapshot, _metric_guard: SandboxMetricsGuard::new(), }) @@ -137,7 +133,6 @@ impl JSSandbox { handlers: snapshot.metadata().handlers().collect(), modules: snapshot.metadata().modules().collect(), host_functions, - guest_has_host_functions: false, snapshot: snapshot.inner(), _metric_guard: SandboxMetricsGuard::new(), }) @@ -168,7 +163,6 @@ impl JSSandbox { snapshot .metadata() .validate_host_functions(&self.host_functions)?; - self.guest_has_host_functions = false; self.inner.restore(snapshot.inner())?; self.handlers = snapshot.metadata().handlers().collect(); self.modules = snapshot.metadata().modules().collect(); @@ -385,9 +379,7 @@ impl JSSandbox { // Publish the builder-selected host functions before compiling modules // and handlers that may import them. - if !self.guest_has_host_functions { - register_host_function_manifest(&mut self.inner, &self.host_functions)?; - } + 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