diff --git a/docs/persistent-snapshots.md b/docs/persistent-snapshots.md index bcec6eb8..fc2f2f9f 100644 --- a/docs/persistent-snapshots.md +++ b/docs/persistent-snapshots.md @@ -155,6 +155,12 @@ 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. +If the builder supplies host functions the snapshot does not record, +hyperlight-js registers them in the guest and copies the guest state. A +`JSSandbox` does this in `build()` and in each `restore()` of such a snapshot. +A `LoadedJSSandbox` does it in `unload()` after such a restore. Supply exactly +the recorded functions to avoid this cost. + 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 @@ -239,6 +245,8 @@ the same output routing is required. version for diagnostics. It also records the guest runtime ABI version. Unsupported metadata schema or guest ABI versions are rejected before starting guest execution. +- Snapshots saved by hyperlight-js 0.5.0 use metadata schema 1 and are + rejected. Recreate them. - 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. diff --git a/src/hyperlight-js/src/sandbox/host_fn.rs b/src/hyperlight-js/src/sandbox/host_fn.rs index 69282208..4fd831dd 100644 --- a/src/hyperlight-js/src/sandbox/host_fn.rs +++ b/src/hyperlight-js/src/sandbox/host_fn.rs @@ -68,6 +68,8 @@ pub(crate) fn register_host_function_manifest( } /// Return the registered host functions grouped by module. +/// +/// Modules without functions are omitted because the guest has nothing to register for them. pub(crate) fn host_function_manifest( modules: &HashMap, ) -> HostFunctionManifest { @@ -81,6 +83,7 @@ pub(crate) fn host_function_manifest( functions.sort(); (module.clone(), functions) }) + .filter(|(_, functions)| !functions.is_empty()) .collect() } diff --git a/src/hyperlight-js/src/sandbox/js_sandbox.rs b/src/hyperlight-js/src/sandbox/js_sandbox.rs index 12f37718..b158e4a4 100644 --- a/src/hyperlight-js/src/sandbox/js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/js_sandbox.rs @@ -74,6 +74,23 @@ pub fn validate_namespace_not_reserved(namespace: &str) -> Result<()> { Ok(()) } +/// Return a base snapshot whose guest state registers `host_functions`. +/// +/// `inner` must hold the guest state of `base`, which registers the host +/// functions recorded in `metadata`. +fn snapshot_base_with_host_functions( + inner: &mut Sandbox, + base: Arc, + metadata: &SnapshotMetadata, + host_functions: &HostFunctionManifest, +) -> Result> { + if metadata.host_functions() == host_functions { + return Ok(base); + } + register_host_function_manifest(inner, host_functions)?; + inner.snapshot() +} + /// A Hyperlight Sandbox with a JavaScript run time loaded but no guest code. pub struct JSSandbox { pub(super) inner: Sandbox, @@ -84,7 +101,8 @@ pub struct JSSandbox { modules: HashMap, // Host functions selected by SandboxBuilder. host_functions: HostFunctionManifest, - // Snapshot of state before any handlers are added. + // Snapshot of state before any handlers are added. Its guest state has + // `host_functions` registered and matches the current guest state. // This is used to restore state back to a neutral JSSandbox. snapshot: Arc, // metric drop guard to manage sandbox metric @@ -109,10 +127,12 @@ impl JSSandbox { pub(crate) fn from_loaded( mut loaded: Sandbox, snapshot: Arc, + metadata: &SnapshotMetadata, host_functions: HostFunctionManifest, ) -> Result { loaded.restore(snapshot.clone())?; - register_host_function_manifest(&mut loaded, &host_functions)?; + let snapshot = + snapshot_base_with_host_functions(&mut loaded, snapshot, metadata, &host_functions)?; Ok(Self { inner: loaded, handlers: HashMap::new(), @@ -124,16 +144,22 @@ impl JSSandbox { } pub(crate) fn from_snapshot( - inner: Sandbox, + mut inner: Sandbox, snapshot: &Snapshot, host_functions: HostFunctionManifest, ) -> Result { + let base = snapshot_base_with_host_functions( + &mut inner, + snapshot.inner(), + snapshot.metadata(), + &host_functions, + )?; Ok(Self { inner, handlers: snapshot.metadata().handlers().collect(), modules: snapshot.metadata().modules().collect(), host_functions, - snapshot: snapshot.inner(), + snapshot: base, _metric_guard: SandboxMetricsGuard::new(), }) } @@ -164,9 +190,23 @@ impl JSSandbox { .metadata() .validate_host_functions(&self.host_functions)?; self.inner.restore(snapshot.inner())?; + let base = snapshot_base_with_host_functions( + &mut self.inner, + snapshot.inner(), + snapshot.metadata(), + &self.host_functions, + ); + self.snapshot = match base { + Ok(base) => base, + Err(error) => { + // Return the guest to the state the unchanged fields describe. + // Report the original error even if that fails. + let _ = self.inner.restore(self.snapshot.clone()); + return Err(error); + } + }; self.handlers = snapshot.metadata().handlers().collect(); self.modules = snapshot.metadata().modules().collect(); - self.snapshot = snapshot.inner(); Ok(()) } @@ -377,10 +417,6 @@ impl JSSandbox { 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 diff --git a/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs b/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs index ac86c4b0..1bb47cae 100644 --- a/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs @@ -38,6 +38,8 @@ use crate::sandbox::metrics::SandboxMetricsGuard; pub struct LoadedJSSandbox { inner: Sandbox, // Snapshot of state before the sandbox was loaded and before any handlers were added. + // Its guest state has the host functions in `snapshot_metadata` registered, + // which `unload` relies on. // This is used to restore state back to a JSSandbox. snapshot: Arc, // Metadata for the currently captured loaded guest state. Its host-function @@ -205,8 +207,13 @@ 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, self.available_host_functions) - .inspect(|_| metrics::counter!(METRIC_SANDBOX_UNLOADS).increment(1)) + JSSandbox::from_loaded( + self.inner, + self.snapshot, + &self.snapshot_metadata, + self.available_host_functions, + ) + .inspect(|_| metrics::counter!(METRIC_SANDBOX_UNLOADS).increment(1)) } /// Take a snapshot of the the current state of the sandbox. diff --git a/src/hyperlight-js/src/sandbox/sandbox_builder.rs b/src/hyperlight-js/src/sandbox/sandbox_builder.rs index d57ca096..a563d168 100644 --- a/src/hyperlight-js/src/sandbox/sandbox_builder.rs +++ b/src/hyperlight-js/src/sandbox/sandbox_builder.rs @@ -424,6 +424,9 @@ impl SandboxBuilder> { /// /// The snapshot's memory layout overrides heap, scratch, and transport /// settings. Supply every host function and module loader the snapshot uses. + /// Host functions the snapshot does not record are registered in the guest, + /// which costs one guest call and a copy of guest state. A `JSSandbox` pays + /// this in `build()`, and a `LoadedJSSandbox` in `unload()`. pub fn from_snapshot(snapshot: Snapshot) -> Self { Self::with_source(snapshot) } diff --git a/src/hyperlight-js/src/sandbox/snapshot.rs b/src/hyperlight-js/src/sandbox/snapshot.rs index 71c43c86..2965ca4f 100644 --- a/src/hyperlight-js/src/sandbox/snapshot.rs +++ b/src/hyperlight-js/src/sandbox/snapshot.rs @@ -30,7 +30,8 @@ use super::js_sandbox::{validate_module_identifier, validate_namespace_not_reser use crate::{new_error, JSSandbox, LoadedJSSandbox, Result, Script}; const METADATA_NAMESPACE: &str = "dev.hyperlight.hyperlight-js.snapshot.v1"; -const METADATA_SCHEMA_VERSION: u32 = 1; +// Version 2 guarantees that a snapshot's base registers its recorded host functions. +const METADATA_SCHEMA_VERSION: u32 = 2; 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(); @@ -119,6 +120,10 @@ impl SnapshotMetadata { self.kind } + pub(crate) fn host_functions(&self) -> &HostFunctionManifest { + &self.host_functions + } + pub(crate) fn handlers(&self) -> impl Iterator + '_ { self.handlers .clone() @@ -543,6 +548,16 @@ mod tests { .contains("produced by hyperlight-js 9.8.7")); } + #[test] + fn metadata_rejects_schema_version_1() { + let mut metadata = metadata(SnapshotKind::JsSandbox); + metadata.schema_version = 1; + + let error = metadata.validate().unwrap_err(); + + assert!(error.to_string().contains("metadata version 1 ")); + } + #[test] fn metadata_rejects_other_guest_abi_version() { let mut metadata = metadata(SnapshotKind::JsSandbox); diff --git a/src/hyperlight-js/tests/host_functions.rs b/src/hyperlight-js/tests/host_functions.rs index b2a71c0a..50e4518c 100644 --- a/src/hyperlight-js/tests/host_functions.rs +++ b/src/hyperlight-js/tests/host_functions.rs @@ -19,7 +19,16 @@ limitations under the License. // Most tests here cover the deprecated ProtoJSSandbox registration API. #![allow(deprecated)] -use hyperlight_js::{HostFunctionModule, LoadedJSSandbox, SandboxBuilder, Script, Snapshot}; +use std::cell::RefCell; +use std::sync::Once; + +use hyperlight_js::{ + HostFunctionModule, JSSandbox, LoadedJSSandbox, SandboxBuilder, Script, Snapshot, +}; +use tracing::field::{Field, Visit}; +use tracing::span::{Attributes, Id}; +use tracing::{Metadata, Subscriber}; +use tracing_subscriber::layer::{Context, Layer, SubscriberExt}; #[test] fn can_call_host_functions() { @@ -1243,3 +1252,243 @@ fn restored_js_sandbox_records_supplied_host_functions() { let error = insufficient.restore(complete_snapshot).unwrap_err(); assert!(error.to_string().contains("extra")); } + +fn extra_handler() -> Script { + Script::from_content( + r#" + import * as extra from "extra"; + function handler(event) { return { value: extra.value(0) }; } + "#, + ) +} + +fn assert_extra_handler_runs(mut sandbox: JSSandbox) -> JSSandbox { + sandbox.add_handler("extra", extra_handler()).unwrap(); + let mut loaded = sandbox.get_loaded_sandbox().unwrap(); + let result = loaded.handle_event("extra", "{}".to_owned(), None).unwrap(); + assert_eq!(result, r#"{"value":2}"#); + loaded.unload().unwrap() +} + +fn both_host_function_modules() -> [HostFunctionModule; 2] { + [ + required_host_function_module(), + extra_host_function_module(), + ] +} + +#[test] +fn handlers_call_host_functions_across_reload_cycles() { + let mut sandbox = SandboxBuilder::new() + .with_host_function_modules(both_host_function_modules()) + .build() + .unwrap() + .load_runtime() + .unwrap(); + for _ in 0..5 { + sandbox = assert_extra_handler_runs(sandbox); + } +} + +#[test] +fn restored_js_sandbox_can_call_supplied_host_functions() { + let mut source = SandboxBuilder::new() + .with_host_function_modules([required_host_function_module()]) + .build() + .unwrap() + .load_runtime() + .unwrap(); + let snapshot = source.snapshot().unwrap(); + + let mut restored = SandboxBuilder::from_snapshot(snapshot) + .with_host_function_modules(both_host_function_modules()) + .build() + .unwrap(); + for _ in 0..3 { + restored = assert_extra_handler_runs(restored); + } + + let snapshot = restored.snapshot().unwrap(); + let restored = SandboxBuilder::from_snapshot(snapshot) + .with_host_function_modules(both_host_function_modules()) + .build() + .unwrap(); + assert_extra_handler_runs(restored); +} + +#[test] +fn restore_into_sandbox_with_more_host_functions_can_call_them() { + let snapshot = SandboxBuilder::new() + .with_host_function_modules([required_host_function_module()]) + .build() + .unwrap() + .load_runtime() + .unwrap() + .snapshot() + .unwrap(); + + let mut destination = SandboxBuilder::new() + .with_host_function_modules(both_host_function_modules()) + .build() + .unwrap() + .load_runtime() + .unwrap(); + destination.restore(snapshot).unwrap(); + for _ in 0..3 { + destination = assert_extra_handler_runs(destination); + } +} + +#[test] +fn loaded_restore_into_sandbox_with_more_host_functions_can_call_them_after_unload() { + let mut destination = SandboxBuilder::new() + .with_host_function_modules(both_host_function_modules()) + .build() + .unwrap() + .load_runtime() + .unwrap(); + destination + .add_handler( + "destination", + Script::from_content("function handler(event) { return event; }"), + ) + .unwrap(); + let mut loaded = destination.get_loaded_sandbox().unwrap(); + loaded + .restore(loaded_snapshot_requiring_host_function()) + .unwrap(); + let result = loaded + .handle_event("source", "{}".to_owned(), None) + .unwrap(); + assert_eq!(result, r#"{"value":1}"#); + + let mut unloaded = loaded.unload().unwrap(); + for _ in 0..2 { + unloaded = assert_extra_handler_runs(unloaded); + } +} + +#[test] +fn restored_loaded_sandbox_snapshot_after_unload_can_call_supplied_host_functions() { + let snapshot = loaded_snapshot_requiring_host_function(); + let restored = SandboxBuilder::from_snapshot(snapshot) + .with_host_function_modules(both_host_function_modules()) + .build() + .unwrap(); + let mut unloaded = assert_extra_handler_runs(restored.unload().unwrap()); + + let snapshot = unloaded.snapshot().unwrap(); + let restored = SandboxBuilder::from_snapshot(snapshot) + .with_host_function_modules(both_host_function_modules()) + .build() + .unwrap(); + assert_extra_handler_runs(restored); +} + +thread_local! { + static GUEST_CALLS: RefCell>> = const { RefCell::new(None) }; +} + +// Records the `func_name` of hyperlight-host's instrumented `Sandbox::call` spans +// on threads inside `guest_calls`. It is a global subscriber because tracing caches +// callsite interest per process, so thread-scoped subscribers can miss spans. +struct GuestCalls; + +impl Layer for GuestCalls { + fn enabled(&self, metadata: &Metadata<'_>, _: Context<'_, S>) -> bool { + metadata.name() == "call" && metadata.target().starts_with("hyperlight_host") + } + + fn on_new_span(&self, attrs: &Attributes<'_>, _: &Id, _: Context<'_, S>) { + struct FuncName(Option); + impl Visit for FuncName { + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "func_name" { + self.0 = Some(value.to_owned()); + } + } + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "func_name" { + self.0 = Some(format!("{value:?}").trim_matches('"').to_owned()); + } + } + } + let mut name = FuncName(None); + attrs.record(&mut name); + GUEST_CALLS.with_borrow_mut(|calls| { + if let Some(calls) = calls { + calls.extend(name.0); + } + }); + } +} + +fn guest_calls(f: impl FnOnce() -> R) -> (R, Vec) { + static INIT: Once = Once::new(); + INIT.call_once(|| { + tracing::subscriber::set_global_default(tracing_subscriber::registry().with(GuestCalls)) + .unwrap(); + }); + // Replace interest a callsite may have cached before the subscriber was installed. + tracing::callsite::rebuild_interest_cache(); + GUEST_CALLS.set(Some(Vec::new())); + let result = f(); + (result, GUEST_CALLS.take().unwrap()) +} + +#[test] +fn reload_cycle_makes_two_guest_calls() { + let mut sandbox = SandboxBuilder::new() + .with_host_function_modules(both_host_function_modules()) + .build() + .unwrap() + .load_runtime() + .unwrap(); + + let (_, calls) = guest_calls(|| { + for _ in 0..3 { + sandbox = assert_extra_handler_runs(sandbox); + } + }); + + assert_eq!(calls.len(), 6, "guest calls: {calls:?}"); + assert!(calls + .chunks(2) + .all(|cycle| cycle == ["register_handler", "RunHandler"])); +} + +#[test] +fn restore_registers_host_functions_only_when_they_differ() { + let snapshot = SandboxBuilder::new() + .with_host_function_modules([required_host_function_module()]) + .build() + .unwrap() + .load_runtime() + .unwrap() + .snapshot() + .unwrap(); + let restore = |modules: Vec| { + guest_calls(|| { + SandboxBuilder::from_snapshot(snapshot.clone()) + .with_host_function_modules(modules) + .build() + .unwrap() + }) + }; + + let (_, calls) = restore(vec![ + required_host_function_module(), + HostFunctionModule::new("empty"), + ]); + assert!(calls.is_empty(), "guest calls: {calls:?}"); + + let (mut sandbox, calls) = restore(both_host_function_modules().into()); + assert_eq!(calls, ["RegisterHostModules"]); + + let (_, calls) = guest_calls(|| { + for _ in 0..2 { + sandbox = assert_extra_handler_runs(sandbox); + } + }); + assert_eq!(calls.len(), 4, "guest calls: {calls:?}"); +}