From 017328c54c4475e82ee824e9fe2d60fccdd59dac Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:50:43 -0700 Subject: [PATCH 01/12] Introduce HostFunctions newtype for sandbox construction Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- .../src/func/host_functions.rs | 26 +++++- src/hyperlight_host/src/lib.rs | 3 + src/hyperlight_host/src/sandbox/host_funcs.rs | 93 +++++++++++++++++-- .../src/sandbox/uninitialized.rs | 9 +- 4 files changed, 112 insertions(+), 19 deletions(-) diff --git a/src/hyperlight_host/src/func/host_functions.rs b/src/hyperlight_host/src/func/host_functions.rs index e87fa70b0..9ccb98f05 100644 --- a/src/hyperlight_host/src/func/host_functions.rs +++ b/src/hyperlight_host/src/func/host_functions.rs @@ -52,7 +52,8 @@ impl Registerable for UninitializedSandbox { return_type: Output::TYPE, }; - (*hfs).register_host_function(name.to_string(), entry) + (*hfs).register_host_function(name.to_string(), entry); + Ok(()) } } @@ -92,7 +93,26 @@ impl Registerable for crate::MultiUseSandbox { return_type: Output::TYPE, }; - (*hfs).register_host_function(name.to_string(), entry) + (*hfs).register_host_function(name.to_string(), entry); + Ok(()) + } +} + +impl Registerable for crate::HostFunctions { + fn register_host_function( + &mut self, + name: &str, + hf: impl Into>, + ) -> Result<()> { + let entry = FunctionEntry { + function: hf.into().into(), + parameter_types: Args::TYPE, + return_type: Output::TYPE, + }; + + self.inner_mut() + .register_host_function(name.to_string(), entry); + Ok(()) } } @@ -236,7 +256,7 @@ pub(crate) fn register_host_function, } -impl From<&mut FunctionRegistry> for HostFunctionDetails { - fn from(registry: &mut FunctionRegistry) -> Self { +/// A collection of host functions that can be supplied to a sandbox +/// constructor (e.g. [`crate::MultiUseSandbox::from_snapshot`]) to +/// expose host-side functionality to the guest. +/// +/// Use [`HostFunctions::default`] to start with the standard +/// `HostPrint` function pre-registered (matches the registry that the +/// regular `UninitializedSandbox` → `evolve()` path constructs), or +/// [`HostFunctions::empty`] to start with an empty registry. +/// +/// Add additional host functions via the +/// [`crate::func::Registerable`] trait, just as you would on an +/// `UninitializedSandbox`. +/// +/// ```no_run +/// # use hyperlight_host::{HostFunctions, Result}; +/// # use hyperlight_host::func::Registerable; +/// # fn example() -> Result<()> { +/// // Default: HostPrint already registered. +/// let mut funcs = HostFunctions::default(); +/// funcs.register_host_function("Add", |a: i32, b: i32| Ok(a + b))?; +/// # Ok(()) +/// # } +/// ``` +pub struct HostFunctions(FunctionRegistry); + +impl HostFunctions { + /// Create an empty `HostFunctions` with no host functions + /// registered. + /// + /// Most callers want [`HostFunctions::default`] instead, which + /// pre-registers the standard `HostPrint` function. An empty + /// registry will fail snapshot validation against any snapshot + /// that captured `HostPrint`, and any guest code that tries to + /// `printf` into an empty registry will get an EIO from + /// `write(2)`. + pub fn empty() -> Self { + Self(FunctionRegistry::default()) + } + + /// Borrow the inner registry mutably. + pub(crate) fn inner_mut(&mut self) -> &mut FunctionRegistry { + &mut self.0 + } +} + +impl Default for HostFunctions { + /// Create a `HostFunctions` pre-populated with the standard + /// `HostPrint` function (writes UTF-8 strings to the host's + /// stdout in green). + /// + /// This matches the default registry installed by + /// `UninitializedSandbox::new()`, so a snapshot taken from a + /// regular sandbox can be loaded with + /// `MultiUseSandbox::from_snapshot(snap, HostFunctions::default(), None)` + /// without registering anything else. + /// + /// Use [`HostFunctions::empty`] for an empty registry. + fn default() -> Self { + Self(FunctionRegistry::with_default_host_print()) + } +} + +impl From<&FunctionRegistry> for HostFunctionDetails { + fn from(registry: &FunctionRegistry) -> Self { let host_functions = registry .functions_map .iter() @@ -61,15 +123,26 @@ pub struct FunctionEntry { impl FunctionRegistry { /// Register a host function with the sandbox. - #[instrument(err(Debug), skip_all, parent = Span::current(), level = "Trace")] - pub(crate) fn register_host_function( - &mut self, - name: String, - func: FunctionEntry, - ) -> Result<()> { + #[instrument(skip_all, parent = Span::current(), level = "Trace")] + pub(crate) fn register_host_function(&mut self, name: String, func: FunctionEntry) { self.functions_map.insert(name, func); + } - Ok(()) + /// Create a `FunctionRegistry` pre-populated with the default + /// `HostPrint` function (writes to stdout with green text). + pub(crate) fn with_default_host_print() -> Self { + use crate::func::host_functions::HostFunction; + use crate::func::{ParameterTuple, SupportedReturnType}; + + let mut registry = Self::default(); + let hf: HostFunction = default_writer_func.into(); + let entry = FunctionEntry { + function: hf.into(), + parameter_types: <(String,)>::TYPE, + return_type: ::TYPE, + }; + registry.register_host_function("HostPrint".to_string(), entry); + registry } /// Assuming a host function called `"HostPrint"` exists, and takes a @@ -118,7 +191,7 @@ impl FunctionRegistry { /// The default writer function is to write to stdout with green text. #[instrument(err(Debug), skip_all, parent = Span::current(), level = "Trace")] -pub(super) fn default_writer_func(s: String) -> Result { +fn default_writer_func(s: String) -> Result { match std::io::stdout().is_terminal() { false => { print!("{}", s); diff --git a/src/hyperlight_host/src/sandbox/uninitialized.rs b/src/hyperlight_host/src/sandbox/uninitialized.rs index 23c01be28..b65b1e655 100644 --- a/src/hyperlight_host/src/sandbox/uninitialized.rs +++ b/src/hyperlight_host/src/sandbox/uninitialized.rs @@ -22,7 +22,7 @@ use std::sync::{Arc, Mutex}; use tracing::{Span, instrument}; use tracing_core::LevelFilter; -use super::host_funcs::{FunctionRegistry, default_writer_func}; +use super::host_funcs::FunctionRegistry; use super::snapshot::Snapshot; use super::uninitialized_evolve::evolve_impl_multi_use; use crate::func::host_functions::{HostFunction, register_host_function}; @@ -365,9 +365,9 @@ impl UninitializedSandbox { let mem_mgr_wrapper = SandboxMemoryManager::::from_snapshot(snapshot.as_ref())?; - let host_funcs = Arc::new(Mutex::new(FunctionRegistry::default())); + let host_funcs = Arc::new(Mutex::new(FunctionRegistry::with_default_host_print())); - let mut sandbox = Self { + let sandbox = Self { host_funcs, mgr: mem_mgr_wrapper, max_guest_log_level: None, @@ -383,9 +383,6 @@ impl UninitializedSandbox { pending_file_mappings: Vec::new(), }; - // If we were passed a writer for host print register it otherwise use the default. - sandbox.register_print(default_writer_func)?; - crate::debug!("Sandbox created: {:#?}", sandbox); Ok(sandbox) From 7b0eff174dd936651a75209e988e521283818e37 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:50:22 -0700 Subject: [PATCH 02/12] Expose SandboxMemoryLayout fields to crate Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- src/hyperlight_host/src/mem/layout.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/hyperlight_host/src/mem/layout.rs b/src/hyperlight_host/src/mem/layout.rs index 07f82a829..8f1790055 100644 --- a/src/hyperlight_host/src/mem/layout.rs +++ b/src/hyperlight_host/src/mem/layout.rs @@ -226,16 +226,16 @@ pub(crate) struct SandboxMemoryLayout { /// The size of the guest code section. pub(crate) code_size: usize, /// The size of the init data section (guest blob). - init_data_size: usize, + pub(crate) init_data_size: usize, /// Permission flags for the init data region. #[cfg_attr(feature = "i686-guest", allow(unused))] - init_data_permissions: Option, + pub(crate) init_data_permissions: Option, /// The size of the scratch region in physical memory. - scratch_size: usize, + pub(crate) scratch_size: usize, /// The size of the snapshot region in physical memory. - snapshot_size: usize, + pub(crate) snapshot_size: usize, /// The size of the page tables (None if not yet set). - pt_size: Option, + pub(crate) pt_size: Option, } impl Debug for SandboxMemoryLayout { @@ -295,7 +295,7 @@ impl SandboxMemoryLayout { /// Both the scratch region and the snapshot region are bounded by /// this size. The value is arbitrary but chosen to be large enough /// for most workloads while preventing accidental resource exhaustion. - const MAX_MEMORY_SIZE: usize = (16 * 1024 * 1024 * 1024) - Self::BASE_ADDRESS; // 16 GiB - BASE_ADDRESS + pub(crate) const MAX_MEMORY_SIZE: usize = (16 * 1024 * 1024 * 1024) - Self::BASE_ADDRESS; // 16 GiB - BASE_ADDRESS /// The base address of the sandbox's memory. pub(crate) const BASE_ADDRESS: usize = 0x1000; From dc206ab53d64ae1168b94792b47218b35403f899 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:52:41 -0700 Subject: [PATCH 03/12] Capture host function metadata in Snapshot Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- src/hyperlight_host/src/mem/mgr.rs | 4 ++++ .../src/sandbox/initialized_multi_use.rs | 6 ++++++ src/hyperlight_host/src/sandbox/snapshot/mod.rs | 16 ++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/src/hyperlight_host/src/mem/mgr.rs b/src/hyperlight_host/src/mem/mgr.rs index 9e5d843d1..9dede7083 100644 --- a/src/hyperlight_host/src/mem/mgr.rs +++ b/src/hyperlight_host/src/mem/mgr.rs @@ -22,6 +22,7 @@ use hyperlight_common::flatbuffer_wrappers::function_call::{ }; use hyperlight_common::flatbuffer_wrappers::function_types::FunctionCallResult; use hyperlight_common::flatbuffer_wrappers::guest_log_data::GuestLogData; +use hyperlight_common::flatbuffer_wrappers::host_function_details::HostFunctionDetails; use hyperlight_common::vmem::{self, PAGE_TABLE_SIZE}; #[cfg(all(feature = "crashdump", not(feature = "i686-guest")))] use hyperlight_common::vmem::{BasicMapping, MappingKind}; @@ -298,6 +299,7 @@ where } /// Create a snapshot with the given mapped regions + #[allow(clippy::too_many_arguments)] pub(crate) fn snapshot( &mut self, sandbox_id: u64, @@ -306,6 +308,7 @@ where rsp_gva: u64, sregs: CommonSpecialRegisters, entrypoint: NextAction, + host_functions: HostFunctionDetails, ) -> Result { self.snapshot_count += 1; Snapshot::new( @@ -320,6 +323,7 @@ where sregs, entrypoint, self.snapshot_count, + host_functions, ) } } diff --git a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs index 241622cab..e6672f874 100644 --- a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs +++ b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs @@ -207,6 +207,11 @@ impl MultiUseSandbox { .get_snapshot_sregs() .map_err(|e| HyperlightError::HyperlightVmError(e.into()))?; let entrypoint = self.vm.get_entrypoint(); + let host_functions = (&*self.host_funcs.try_lock().map_err(|e| { + crate::new_error!("Error locking host_funcs at {}:{}: {}", file!(), line!(), e) + })?) + .into(); + let memory_snapshot = self.mem_mgr.snapshot( self.id, mapped_regions_vec, @@ -214,6 +219,7 @@ impl MultiUseSandbox { stack_top_gpa, sregs, entrypoint, + host_functions, )?; let snapshot = Arc::new(memory_snapshot); self.snapshot = Some(snapshot.clone()); diff --git a/src/hyperlight_host/src/sandbox/snapshot/mod.rs b/src/hyperlight_host/src/sandbox/snapshot/mod.rs index e4c7b1133..100ab9df3 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/mod.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/mod.rs @@ -17,6 +17,7 @@ limitations under the License. use std::collections::{BTreeMap, HashMap}; use std::sync::atomic::{AtomicU64, Ordering}; +use hyperlight_common::flatbuffer_wrappers::host_function_details::HostFunctionDetails; use hyperlight_common::layout::{scratch_base_gpa, scratch_base_gva}; use hyperlight_common::vmem; use hyperlight_common::vmem::{ @@ -115,6 +116,13 @@ pub struct Snapshot { /// restored sandbox's guest-visible counter so the guest can tell /// which snapshot it is currently a clone of. snapshot_generation: u64, + + /// Names and signatures of host functions registered on the + /// sandbox at the time this snapshot was taken. Used by + /// [`crate::MultiUseSandbox::from_snapshot`] to reject a + /// `HostFunctions` set that is missing required functions or + /// has mismatched signatures. + host_functions: HostFunctionDetails, } impl core::convert::AsRef for Snapshot { fn as_ref(&self) -> &Self { @@ -423,6 +431,9 @@ impl Snapshot { sregs: None, entrypoint: NextAction::Initialise(load_addr + entrypoint_va - base_va), snapshot_generation: 0, + host_functions: HostFunctionDetails { + host_functions: None, + }, }) } @@ -447,6 +458,7 @@ impl Snapshot { sregs: CommonSpecialRegisters, entrypoint: NextAction, snapshot_generation: u64, + host_functions: HostFunctionDetails, ) -> Result { let mut phys_seen = HashMap::::new(); let scratch_gva = scratch_base_gva(layout.get_scratch_size()); @@ -610,6 +622,7 @@ impl Snapshot { sregs: Some(sregs), entrypoint, snapshot_generation, + host_functions, }) } @@ -674,6 +687,7 @@ impl PartialEq for Snapshot { #[cfg(test)] #[cfg(not(feature = "i686-guest"))] mod tests { + use hyperlight_common::flatbuffer_wrappers::host_function_details::HostFunctionDetails; use hyperlight_common::vmem::{self, BasicMapping, Mapping, MappingKind, PAGE_SIZE}; use crate::hypervisor::regs::CommonSpecialRegisters; @@ -747,6 +761,7 @@ mod tests { default_sregs(), super::NextAction::None, 1, + HostFunctionDetails::default(), ) .unwrap(); @@ -764,6 +779,7 @@ mod tests { default_sregs(), super::NextAction::None, 2, + HostFunctionDetails::default(), ) .unwrap(); From 59c9a35575a1e46093981ddc1c598ac05f106d0a Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Fri, 1 May 2026 13:24:48 -0700 Subject: [PATCH 04/12] Make gdb work for already initialised snapshots Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- .../src/hypervisor/gdb/arch.rs | 9 +----- .../src/hypervisor/gdb/event_loop.rs | 1 - src/hyperlight_host/src/hypervisor/gdb/mod.rs | 4 --- .../src/hypervisor/hyperlight_vm/mod.rs | 32 +++++++++++++++---- .../src/hypervisor/hyperlight_vm/x86_64.rs | 17 ++++++++-- 5 files changed, 40 insertions(+), 23 deletions(-) diff --git a/src/hyperlight_host/src/hypervisor/gdb/arch.rs b/src/hyperlight_host/src/hypervisor/gdb/arch.rs index b2ebb82a3..67da6f5b2 100644 --- a/src/hyperlight_host/src/hypervisor/gdb/arch.rs +++ b/src/hyperlight_host/src/hypervisor/gdb/arch.rs @@ -61,9 +61,8 @@ pub(crate) const DR6_HW_BP_FLAGS_MASK: u64 = 0x0F << DR6_HW_BP_FLAGS_POS; /// Determine the reason the vCPU stopped /// This is done by checking the DR6 register and the exception id pub(crate) fn vcpu_stop_reason( - vm: &mut dyn DebuggableVm, + vm: &dyn DebuggableVm, dr6: u64, - entrypoint: u64, exception: u32, ) -> std::result::Result { let CommonRegisters { rip, .. } = vm.regs()?; @@ -81,10 +80,6 @@ pub(crate) fn vcpu_stop_reason( // Check page 19-4 Vol. 3B of Intel 64 and IA-32 // Architectures Software Developer's Manual if DR6_HW_BP_FLAGS_MASK & dr6 != 0 { - if rip == entrypoint { - vm.remove_hw_breakpoint(entrypoint)?; - return Ok(VcpuStopReason::EntryPointBp); - } return Ok(VcpuStopReason::HwBp); } } @@ -98,12 +93,10 @@ pub(crate) fn vcpu_stop_reason( r"The vCPU exited because of an unknown reason: rip: {:?} dr6: {:?} - entrypoint: {:?} exception: {:?} ", rip, dr6, - entrypoint, exception, ); diff --git a/src/hyperlight_host/src/hypervisor/gdb/event_loop.rs b/src/hyperlight_host/src/hypervisor/gdb/event_loop.rs index bc7c9fd14..5edd81b50 100644 --- a/src/hyperlight_host/src/hypervisor/gdb/event_loop.rs +++ b/src/hyperlight_host/src/hypervisor/gdb/event_loop.rs @@ -59,7 +59,6 @@ impl run_blocking::BlockingEventLoop for GdbBlockingEventLoop { // Resume execution if unknown reason for stop let stop_response = match stop_reason { VcpuStopReason::DoneStep => BaseStopReason::DoneStep, - VcpuStopReason::EntryPointBp => BaseStopReason::HwBreak(()), VcpuStopReason::SwBp => BaseStopReason::SwBreak(()), VcpuStopReason::HwBp => BaseStopReason::HwBreak(()), // This is a consequence of the GDB client sending an interrupt signal diff --git a/src/hyperlight_host/src/hypervisor/gdb/mod.rs b/src/hyperlight_host/src/hypervisor/gdb/mod.rs index 94396e5ae..0a0685f71 100644 --- a/src/hyperlight_host/src/hypervisor/gdb/mod.rs +++ b/src/hyperlight_host/src/hypervisor/gdb/mod.rs @@ -171,10 +171,6 @@ impl DebugMemoryAccess { pub enum VcpuStopReason { Crash, DoneStep, - /// Hardware breakpoint inserted by the hypervisor so the guest can be stopped - /// at the entry point. This is used to avoid the guest from executing - /// the entry point code before the debugger is connected - EntryPointBp, HwBp, SwBp, Interrupt, diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs index 830b856c0..5268bd665 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs @@ -389,6 +389,13 @@ pub(crate) struct HyperlightVm { pub(super) gdb_conn: Option>, #[cfg(gdb)] pub(super) sw_breakpoints: HashMap, // addr -> original instruction + /// One-shot hw breakpoint installed at the entry address when gdb is + /// enabled, so the gdb stub gets a `VcpuStopped` to enter its event + /// loop on the first vCPU run after construction. Cleared by the + /// `VmExit::Debug` arm of `run` the first time a `HwBp` stop fires + /// at the entry address. + #[cfg(gdb)] + pub(super) one_shot_entry_bp: Option, #[cfg(feature = "mem_profile")] pub(super) trace_info: MemTraceInfo, #[cfg(crashdump)] @@ -598,17 +605,28 @@ impl HyperlightVm { match exit_reason { #[cfg(gdb)] Ok(VmExit::Debug { dr6, exception }) => { - let initialise = match self.entrypoint { - NextAction::Initialise(initialise) => initialise, - _ => 0, - }; - // Handle debug event (breakpoints) + // Classify the debug exit. `vcpu_stop_reason` is a + // pure classifier and has no side effects on the VM. let stop_reason = crate::hypervisor::gdb::arch::vcpu_stop_reason( - self.vm.as_mut(), + self.vm.as_ref(), dr6, - initialise, exception, )?; + // Remove the one-shot entry breakpoint installed by + // `HyperlightVm::new` the first time it fires so it + // does not interfere with later user-installed + // breakpoints at the same address. + if matches!(stop_reason, VcpuStopReason::HwBp) + && let Some(entry_addr) = self.one_shot_entry_bp + { + let rip = self.vm.regs().map_err(VcpuStopReasonError::GetRegs)?.rip; + if rip == entry_addr { + self.vm + .remove_hw_breakpoint(entry_addr) + .map_err(VcpuStopReasonError::RemoveHwBreakpoint)?; + self.one_shot_entry_bp = None; + } + } if let Err(e) = self.handle_debug(dbg_mem_access_fn.clone(), stop_reason) { break Err(e.into()); } diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs index 16ac55ad3..6feff13f1 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs @@ -168,6 +168,8 @@ impl HyperlightVm { gdb_conn, #[cfg(gdb)] sw_breakpoints: HashMap::new(), + #[cfg(gdb)] + one_shot_entry_bp: None, #[cfg(feature = "mem_profile")] trace_info, #[cfg(crashdump)] @@ -182,12 +184,21 @@ impl HyperlightVm { #[cfg(gdb)] if ret.gdb_conn.is_some() { ret.send_dbg_msg(DebugResponse::InterruptHandle(ret.interrupt_handle.clone()))?; - // Add breakpoint to the entry point address, if we are going to initialise + // Add breakpoint at the entry point address. The breakpoint + // is removed on first hit by the run loop. Tracked via + // `one_shot_entry_bp` so it does not interfere with later + // user-installed breakpoints at the same address. ret.vm.set_debug(true).map_err(VmError::Debug)?; - if let NextAction::Initialise(initialise) = entrypoint { + let entry_addr = match entrypoint { + NextAction::Initialise(addr) | NextAction::Call(addr) => Some(addr), + #[cfg(test)] + NextAction::None => None, + }; + if let Some(addr) = entry_addr { ret.vm - .add_hw_breakpoint(initialise) + .add_hw_breakpoint(addr) .map_err(CreateHyperlightVmError::AddHwBreakpoint)?; + ret.one_shot_entry_bp = Some(addr); } } From 02fd52eb416f5aae4bb1a8ef06c2ba138f36cefa Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:53:01 -0700 Subject: [PATCH 05/12] Add MultiUseSandbox from_snapshot Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- .../src/func/host_functions.rs | 5 + src/hyperlight_host/src/mem/mgr.rs | 8 +- src/hyperlight_host/src/sandbox/host_funcs.rs | 10 + .../src/sandbox/initialized_multi_use.rs | 217 +++++++++++++++++- .../src/sandbox/snapshot/mod.rs | 59 +++++ 5 files changed, 297 insertions(+), 2 deletions(-) diff --git a/src/hyperlight_host/src/func/host_functions.rs b/src/hyperlight_host/src/func/host_functions.rs index 9ccb98f05..ec720d911 100644 --- a/src/hyperlight_host/src/func/host_functions.rs +++ b/src/hyperlight_host/src/func/host_functions.rs @@ -94,6 +94,11 @@ impl Registerable for crate::MultiUseSandbox { }; (*hfs).register_host_function(name.to_string(), entry); + + // Registration mutates the host-function set captured in + // snapshots. Invalidate the cached snapshot so the next + // `snapshot()` call reflects the updated registry. + self.snapshot = None; Ok(()) } } diff --git a/src/hyperlight_host/src/mem/mgr.rs b/src/hyperlight_host/src/mem/mgr.rs index 9dede7083..d77779ce3 100644 --- a/src/hyperlight_host/src/mem/mgr.rs +++ b/src/hyperlight_host/src/mem/mgr.rs @@ -334,7 +334,13 @@ impl SandboxMemoryManager { let shared_mem = s.memory().to_mgr_snapshot_mem()?; let scratch_mem = ExclusiveSharedMemory::new(s.layout().get_scratch_size())?; let entrypoint = s.entrypoint(); - Ok(Self::new(layout, shared_mem, scratch_mem, entrypoint)) + let mut mgr = Self::new(layout, shared_mem, scratch_mem, entrypoint); + // Inherit the snapshot's generation number for the same + // reason `restore_snapshot` does: the guest-visible counter + // reflects "which snapshot is the sandbox currently a clone + // of", not "how many snapshots this partition has taken". + mgr.snapshot_count = s.snapshot_generation(); + Ok(mgr) } /// Wraps ExclusiveSharedMemory::build diff --git a/src/hyperlight_host/src/sandbox/host_funcs.rs b/src/hyperlight_host/src/sandbox/host_funcs.rs index 551eac68e..c271e6c17 100644 --- a/src/hyperlight_host/src/sandbox/host_funcs.rs +++ b/src/hyperlight_host/src/sandbox/host_funcs.rs @@ -74,10 +74,20 @@ impl HostFunctions { Self(FunctionRegistry::default()) } + /// Consume this `HostFunctions` and return the inner registry. + pub(crate) fn into_inner(self) -> FunctionRegistry { + self.0 + } + /// Borrow the inner registry mutably. pub(crate) fn inner_mut(&mut self) -> &mut FunctionRegistry { &mut self.0 } + + /// Borrow the inner registry immutably. + pub(crate) fn inner(&self) -> &FunctionRegistry { + &self.0 + } } impl Default for HostFunctions { diff --git a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs index e6672f874..312e5eda0 100644 --- a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs +++ b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs @@ -93,7 +93,7 @@ pub struct MultiUseSandbox { dbg_mem_access_fn: Arc>>, /// If the current state of the sandbox has been captured in a snapshot, /// that snapshot is stored here. - snapshot: Option>, + pub(crate) snapshot: Option>, /// Optional callback to discover page table roots from guest memory. /// Given (snapshot_mem, scratch_mem, cr3), returns a list of root GPAs. /// If not set, only CR3 is used as the single root. @@ -145,6 +145,179 @@ impl MultiUseSandbox { self.pt_root_finder = Some(finder); } + /// Create a `MultiUseSandbox` directly from a [`Snapshot`], + /// bypassing [`UninitializedSandbox`](crate::UninitializedSandbox) + /// and [`evolve()`](crate::UninitializedSandbox::evolve). + /// + /// This is useful for fast sandbox creation when a snapshot of + /// an already-initialized guest is available, either saved to disk + /// or captured in memory from another sandbox. + /// + /// The provided [`HostFunctions`] must include every host function + /// that was registered on the sandbox at the time the snapshot was + /// taken (matched by name and signature). Additional host functions + /// not present in the snapshot are allowed. + /// + /// An optional [`SandboxConfiguration`](crate::sandbox::SandboxConfiguration) + /// can be supplied to override runtime settings such as timeouts and + /// interrupt behavior. Memory layout fields + /// (`input_data_size`, `output_data_size`, `heap_size`, `scratch_size`) + /// are always taken from the snapshot. Any values supplied in + /// `config` for those fields are ignored. + /// + /// # Examples + /// + /// From a snapshot taken on another sandbox: + /// + /// ```no_run + /// # use std::sync::Arc; + /// # use hyperlight_host::{HostFunctions, MultiUseSandbox, UninitializedSandbox, GuestBinary}; + /// # fn example() -> Result<(), Box> { + /// // Create and initialize a sandbox the normal way + /// let mut sandbox: MultiUseSandbox = UninitializedSandbox::new( + /// GuestBinary::FilePath("guest.bin".into()), + /// None, + /// )?.evolve()?; + /// + /// // Capture a snapshot of the initialized state + /// let snapshot = sandbox.snapshot()?; + /// + /// // Create a new sandbox directly from the snapshot + /// let mut sandbox2 = MultiUseSandbox::from_snapshot(snapshot, HostFunctions::default(), None)?; + /// let result: i32 = sandbox2.call("GetValue", ())?; + /// # Ok(()) + /// # } + /// ``` + #[instrument(err(Debug), skip_all, parent = Span::current(), level = "Trace")] + pub fn from_snapshot( + snapshot: Arc, + host_funcs: crate::HostFunctions, + config: Option, + ) -> Result { + use rand::RngExt; + + use crate::mem::ptr::RawPtr; + use crate::sandbox::uninitialized_evolve::set_up_hypervisor_partition; + + // Validate that the provided host functions are a superset of + // those required by the snapshot. + snapshot.validate_host_functions(&host_funcs)?; + + let host_funcs = Arc::new(Mutex::new(host_funcs.into_inner())); + + let stack_top_gva = snapshot.stack_top_gva(); + // Start from the caller's config (if any) so runtime fields + // such as timeouts and interrupt knobs are honored, then + // overwrite the layout fields from the snapshot. The on-disk + // layout is fixed, so any layout values supplied by the + // caller are silently ignored. Warn if the caller passed a + // config whose layout fields disagree with the snapshot, so + // the override is at least visible. + let caller_supplied_config = config.is_some(); + let mut config = config.unwrap_or_default(); + if caller_supplied_config { + warn_on_layout_override(&config, snapshot.layout()); + } + config.set_input_data_size(snapshot.layout().input_data_size); + config.set_output_data_size(snapshot.layout().output_data_size); + config.set_heap_size(snapshot.layout().heap_size as u64); + config.set_scratch_size(snapshot.layout().get_scratch_size()); + let load_info = snapshot.load_info(); + + let mgr = crate::mem::mgr::SandboxMemoryManager::from_snapshot(&snapshot)?; + let (mut hshm, gshm) = mgr.build()?; + + let page_size = u32::try_from(page_size::get())? as usize; + + #[cfg(target_os = "linux")] + crate::signal_handlers::setup_signal_handlers(&config)?; + + // Build the runtime config from the caller's `SandboxConfiguration` + // so that `guest_core_dump` (crashdump) and `guest_debug_info` (gdb) + // take effect just like they do in the normal evolve path. + // `binary_path` and `entry_point` are not available from a snapshot + // and are left unset. This only affects metadata in core dumps. + #[cfg(any(crashdump, gdb))] + let rt_cfg = crate::sandbox::uninitialized::SandboxRuntimeConfig { + #[cfg(crashdump)] + binary_path: None, + #[cfg(gdb)] + debug_info: config.get_guest_debug_info(), + #[cfg(crashdump)] + guest_core_dump: config.get_guest_core_dump(), + #[cfg(crashdump)] + entry_point: None, + }; + + let mut vm = set_up_hypervisor_partition( + gshm, + &config, + stack_top_gva, + page_size, + #[cfg(any(crashdump, gdb))] + rt_cfg, + load_info, + )?; + + let seed = { + let mut rng = rand::rng(); + rng.random::() + }; + let peb_addr = RawPtr::from(u64::try_from(hshm.layout.peb_address())?); + + #[cfg(gdb)] + let dbg_mem_access_hdl = Arc::new(Mutex::new(hshm.clone())); + + vm.initialise( + peb_addr, + seed, + page_size as u32, + &mut hshm, + &host_funcs, + None, + #[cfg(gdb)] + dbg_mem_access_hdl, + ) + .map_err(crate::hypervisor::hyperlight_vm::HyperlightVmError::Initialize)?; + + // If the snapshot was taken from an already-initialized guest + // (NextAction::Call), apply the captured special registers so + // the guest resumes in the correct CPU state. + #[cfg(not(feature = "i686-guest"))] + if matches!(snapshot.entrypoint(), super::snapshot::NextAction::Call(_)) { + let sregs = snapshot.sregs().ok_or_else(|| { + crate::new_error!("snapshot with NextAction::Call must have captured sregs") + })?; + vm.apply_sregs(hshm.layout.get_pt_base_gpa(), sregs) + .map_err(|e| { + crate::HyperlightError::HyperlightVmError( + crate::hypervisor::hyperlight_vm::HyperlightVmError::Restore(e), + ) + })?; + } + + #[cfg(gdb)] + let dbg_mem_wrapper = Arc::new(Mutex::new(hshm.clone())); + + let mut sbox = MultiUseSandbox::from_uninit( + host_funcs, + hshm, + vm, + #[cfg(gdb)] + dbg_mem_wrapper, + ); + // Use the snapshot's sandbox_id so that restore() back to this + // snapshot is permitted. The id is process-local and never + // persisted to disk: `Snapshot::from_oci` assigns a fresh id + // on every load, so two `from_file` calls of the same path + // yield restore-incompatible sandboxes (which is the intended + // safer default). Sandboxes built from clones of the same + // in-memory `Arc` share the id and are mutually + // restore-compatible. + sbox.id = snapshot.sandbox_id(); + Ok(sbox) + } + /// Creates a snapshot of the sandbox's current memory state. /// /// The snapshot is tied to this specific sandbox instance and can only be @@ -949,6 +1122,48 @@ impl std::fmt::Debug for MultiUseSandbox { } } +/// Emit a warning for each memory-layout field in `caller` that +/// disagrees with `snapshot`. Used by [`MultiUseSandbox::from_snapshot`] +/// to surface ignored caller-supplied layout values, since those +/// fields are always taken from the snapshot. +fn warn_on_layout_override( + caller: &crate::sandbox::SandboxConfiguration, + snapshot: &crate::mem::layout::SandboxMemoryLayout, +) { + let mismatches: &[(&str, u64, u64)] = &[ + ( + "input_data_size", + caller.get_input_data_size() as u64, + snapshot.input_data_size as u64, + ), + ( + "output_data_size", + caller.get_output_data_size() as u64, + snapshot.output_data_size as u64, + ), + ( + "heap_size", + caller.get_heap_size(), + snapshot.heap_size as u64, + ), + ( + "scratch_size", + caller.get_scratch_size() as u64, + snapshot.get_scratch_size() as u64, + ), + ]; + for (name, supplied, snap) in mismatches { + if supplied != snap { + tracing::warn!( + "from_snapshot ignoring caller-supplied {} ({}); using snapshot value ({})", + name, + supplied, + snap + ); + } + } +} + #[cfg(test)] mod tests { use std::sync::{Arc, Barrier}; diff --git a/src/hyperlight_host/src/sandbox/snapshot/mod.rs b/src/hyperlight_host/src/sandbox/snapshot/mod.rs index 100ab9df3..4954645b5 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/mod.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/mod.rs @@ -676,6 +676,65 @@ impl Snapshot { pub(crate) fn entrypoint(&self) -> NextAction { self.entrypoint } + + /// Validate that `provided` is a superset of the host functions + /// recorded in this snapshot: every function that was registered + /// at snapshot time must also be present in `provided` with a + /// matching signature. Extras in `provided` are allowed. + /// + /// A snapshot with no recorded host functions (e.g. one + /// produced by a test-only constructor) accepts any `provided` + /// set. + pub(crate) fn validate_host_functions(&self, provided: &crate::HostFunctions) -> Result<()> { + let required = match &self.host_functions.host_functions { + Some(v) => v, + None => return Ok(()), + }; + if required.is_empty() { + return Ok(()); + } + + // Build a HostFunctionDetails view of the provided registry + // using the existing `From<&FunctionRegistry>` impl. + let provided_details: HostFunctionDetails = provided.inner().into(); + let provided_funcs = provided_details.host_functions.unwrap_or_default(); + + let mut missing: Vec = Vec::new(); + let mut signature_mismatches: Vec = Vec::new(); + + for req in required { + match provided_funcs + .iter() + .find(|f| f.function_name == req.function_name) + { + None => missing.push(req.function_name.clone()), + Some(found) + if found.parameter_types != req.parameter_types + || found.return_type != req.return_type => + { + signature_mismatches.push(format!( + "{}: snapshot has {:?} -> {:?}, registered {:?} -> {:?}", + req.function_name, + req.parameter_types, + req.return_type, + found.parameter_types, + found.return_type, + )); + } + Some(_) => {} + } + } + + if missing.is_empty() && signature_mismatches.is_empty() { + return Ok(()); + } + + Err(crate::new_error!( + "snapshot host function mismatch: missing={:?}, signature_mismatches={:?}", + missing, + signature_mismatches + )) + } } impl PartialEq for Snapshot { From 21e1ea101e3d664b15e79548a7a9c8c1d3ea1f36 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Fri, 1 May 2026 13:26:42 -0700 Subject: [PATCH 06/12] Add gdb test for MultiUseSandbox from_snapshot Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- .../examples/guest-debugging/main.rs | 194 ++++++++++++++---- 1 file changed, 150 insertions(+), 44 deletions(-) diff --git a/src/hyperlight_host/examples/guest-debugging/main.rs b/src/hyperlight_host/examples/guest-debugging/main.rs index dc42b0aea..4ced2f8cf 100644 --- a/src/hyperlight_host/examples/guest-debugging/main.rs +++ b/src/hyperlight_host/examples/guest-debugging/main.rs @@ -115,6 +115,76 @@ mod tests { #[cfg(windows)] const GDB_COMMAND: &str = "gdb"; + /// Construct the (out_file_path, cmd_file_path, manifest_dir) + /// triple every gdb test needs. + fn gdb_test_paths(name: &str) -> (String, String, String) { + let out_dir = std::env::var("OUT_DIR").expect("Failed to get out dir"); + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") + .expect("Failed to get manifest dir") + .replace('\\', "/"); + let out_file_path = format!("{out_dir}/{name}.output"); + let cmd_file_path = format!("{out_dir}/{name}-commands.txt"); + (out_file_path, cmd_file_path, manifest_dir) + } + + /// Build a gdb script that connects to `port`, sets a single + /// breakpoint at `breakpoint`, prints `echo_msg` when hit, and + /// detaches before quitting. + /// + /// The breakpoint commands end with `detach` + `quit` instead of + /// `continue`. The previous "inner continue, outer continue, quit" + /// shape races with the inferior exit. After the breakpoint hits + /// and the inner `continue` resumes the guest, the guest may run + /// to completion and the gdb stub may close the remote before gdb + /// has dispatched the outer `continue`, producing a non-zero exit + /// with `Remote connection closed`. Detaching from the breakpoint + /// commands removes that window. The host process keeps running + /// the guest call to completion on its own after detach. + fn single_breakpoint_script( + manifest_dir: &str, + port: u16, + out_file_path: &str, + breakpoint: &str, + echo_msg: &str, + ) -> String { + let cmd = format!( + "file {manifest_dir}/../tests/rust_guests/bin/debug/simpleguest + target remote :{port} + + set pagination off + set logging file {out_file_path} + set logging enabled on + + break {breakpoint} + commands + echo \"{echo_msg}\\n\" + backtrace + + set logging enabled off + detach + quit + end + + continue + " + ); + #[cfg(windows)] + let cmd = format!("set osabi none\n{cmd}"); + cmd + } + + /// Spawn the gdb client to execute the script in `cmd_file_path`. + fn spawn_gdb_client(cmd_file_path: &str) -> std::process::Child { + Command::new(GDB_COMMAND) + .arg("-nx") + .arg("--nw") + .arg("--batch") + .arg("-x") + .arg(cmd_file_path) + .spawn() + .expect("Failed to start gdb") + } + fn write_cmds_file(cmd_file_path: &str, cmd: &str) -> io::Result<()> { let file = File::create(cmd_file_path)?; let mut writer = BufWriter::new(file); @@ -163,14 +233,7 @@ mod tests { // wait 3 seconds for the gdb to connect thread::sleep(Duration::from_secs(3)); - let mut gdb = Command::new(GDB_COMMAND) - .arg("-nx") // Don't load any .gdbinit files - .arg("--nw") - .arg("--batch") - .arg("-x") - .arg(cmd_file_path) - .spawn() - .map_err(|e| new_error!("Failed to start gdb process: {}", e))?; + let mut gdb = spawn_gdb_client(cmd_file_path); // wait 3 seconds for the gdb to connect thread::sleep(Duration::from_secs(10)); @@ -245,38 +308,16 @@ mod tests { #[test] #[serial] fn test_gdb_end_to_end() { - let out_dir = std::env::var("OUT_DIR").expect("Failed to get out dir"); - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") - .expect("Failed to get manifest dir") - .replace('\\', "/"); - let out_file_path = format!("{out_dir}/gdb.output"); - let cmd_file_path = format!("{out_dir}/gdb-commands.txt"); - - let cmd = format!( - "file {manifest_dir}/../tests/rust_guests/bin/debug/simpleguest - target remote :8080 - - set pagination off - set logging file {out_file_path} - set logging enabled on - - break hyperlight_main - commands - echo \"Stopped at hyperlight_main breakpoint\\n\" - backtrace - - set logging enabled off - detach - quit - end - - continue - " + let (out_file_path, cmd_file_path, manifest_dir) = gdb_test_paths("gdb"); + + let cmd = single_breakpoint_script( + &manifest_dir, + 8080, + &out_file_path, + "hyperlight_main", + "Stopped at hyperlight_main breakpoint", ); - #[cfg(windows)] - let cmd = format!("set osabi none\n{}", cmd); - let checker = |contents: String| contents.contains("Stopped at hyperlight_main breakpoint"); let result = run_guest_and_gdb(&cmd_file_path, &out_file_path, &cmd, checker); @@ -288,13 +329,8 @@ mod tests { #[test] #[serial] fn test_gdb_sse_check() { - let out_dir = std::env::var("OUT_DIR").expect("Failed to get out dir"); - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") - .expect("Failed to get manifest dir") - .replace('\\', "/"); + let (out_file_path, cmd_file_path, manifest_dir) = gdb_test_paths("gdb-sse"); println!("manifest dir {manifest_dir}"); - let out_file_path = format!("{out_dir}/gdb-sse.output"); - let cmd_file_path = format!("{out_dir}/gdb-sse--commands.txt"); let cmd = format!( "file {manifest_dir}/../tests/rust_guests/bin/debug/simpleguest @@ -330,4 +366,74 @@ mod tests { cleanup(&out_file_path, &cmd_file_path); assert!(result.is_ok(), "{}", result.unwrap_err()); } + + #[test] + #[serial] + fn test_gdb_from_snapshot() { + use hyperlight_host::HostFunctions; + + const PORT: u16 = 8081; + + let (out_file_path, cmd_file_path, manifest_dir) = gdb_test_paths("gdb-from-snapshot"); + + // Build a sandbox the normal way and snapshot it in-memory. + let mut producer: MultiUseSandbox = UninitializedSandbox::new( + hyperlight_host::GuestBinary::FilePath( + hyperlight_testing::simple_guest_as_string().unwrap(), + ), + None, + ) + .unwrap() + .evolve() + .unwrap(); + let snap = producer.snapshot().unwrap(); + + // Order matters. The gdb stub event loop must enter (i.e. + // `VcpuStopped` must be sent on the channel) before the gdb + // client connects, otherwise the wire protocol desyncs. The + // evolve case gets this for free because `evolve()` runs + // `vm.initialise()` which trips the entry breakpoint + // immediately. For a `Call` snapshot `vm.initialise` is a + // no-op, so we trigger the breakpoint by running `sbox.call` + // here before the client is launched below. + let snap_thread = snap.clone(); + let sandbox_thread = thread::spawn(move || -> Result<()> { + let mut cfg = SandboxConfiguration::default(); + cfg.set_guest_debug_info(DebugInfo { port: PORT }); + + let mut sbox = + MultiUseSandbox::from_snapshot(snap_thread, HostFunctions::default(), Some(cfg))?; + sbox.call::( + "PrintOutput", + "Hello from a from_snapshot sandbox\n".to_string(), + )?; + Ok(()) + }); + + // Wait for the sandbox thread to bind the listener, install + // the one-shot breakpoint, and trip it. + thread::sleep(Duration::from_secs(3)); + + let cmd = single_breakpoint_script( + &manifest_dir, + PORT, + &out_file_path, + "main.rs:simpleguest::print_output", + "Stopped at print_output breakpoint", + ); + write_cmds_file(&cmd_file_path, &cmd).expect("Failed to write gdb commands"); + + let mut gdb = spawn_gdb_client(&cmd_file_path); + let _ = gdb.wait(); + let sandbox_result = sandbox_thread + .join() + .expect("from_snapshot sandbox thread panicked"); + + let checker = |contents: String| contents.contains("Stopped at print_output breakpoint"); + let result = check_output(&out_file_path, checker); + + cleanup(&out_file_path, &cmd_file_path); + sandbox_result.expect("from_snapshot sandbox returned error"); + result.expect("gdb output missing expected breakpoint hit"); + } } From 60f1f7f2fc29d579b38f0dba318884462f33f126 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Thu, 14 May 2026 17:02:42 -0700 Subject: [PATCH 07/12] Add tests for MultiUseSandbox from_snapshot Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- .../src/sandbox/initialized_multi_use.rs | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) diff --git a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs index 312e5eda0..e98b0054b 100644 --- a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs +++ b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs @@ -2809,4 +2809,244 @@ mod tests { } let _ = std::fs::remove_file(&path); } + + /// Tests for [`MultiUseSandbox::from_snapshot`] in-memory. + mod from_snapshot { + use std::sync::Arc; + + use hyperlight_testing::simple_guest_as_string; + + use crate::func::Registerable; + use crate::sandbox::SandboxConfiguration; + use crate::sandbox::snapshot::Snapshot; + use crate::{GuestBinary, HostFunctions, MultiUseSandbox, UninitializedSandbox}; + + fn make_sandbox() -> MultiUseSandbox { + let path = simple_guest_as_string().unwrap(); + UninitializedSandbox::new(GuestBinary::FilePath(path), None) + .unwrap() + .evolve() + .unwrap() + } + + /// Sandbox with an extra `Add(i32, i32) -> i32` host function. + fn make_sandbox_with_add() -> MultiUseSandbox { + let path = simple_guest_as_string().unwrap(); + let mut u = UninitializedSandbox::new(GuestBinary::FilePath(path), None).unwrap(); + u.register_host_function("Add", |a: i32, b: i32| Ok(a + b)) + .unwrap(); + u.evolve().unwrap() + } + + fn host_funcs_with_matching_add() -> HostFunctions { + let mut hf = HostFunctions::default(); + hf.register_host_function("Add", |a: i32, b: i32| Ok(a + b)) + .unwrap(); + hf + } + + #[test] + fn round_trip_running_sandbox() { + let mut sbox = make_sandbox(); + sbox.call::("AddToStatic", 11i32).unwrap(); + let snapshot = sbox.snapshot().unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(snapshot, HostFunctions::default(), None).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 11); + let echoed: String = sbox2.call("Echo", "hi".to_string()).unwrap(); + assert_eq!(echoed, "hi"); + } + + #[test] + fn round_trip_pre_init_snapshot() { + let path = simple_guest_as_string().unwrap(); + let snap = + Snapshot::from_env(GuestBinary::FilePath(path), SandboxConfiguration::default()) + .unwrap(); + let mut sbox = + MultiUseSandbox::from_snapshot(Arc::new(snap), HostFunctions::default(), None) + .unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + } + + /// Sandboxes built from clones of one `Arc` share + /// `sandbox_id` (so both can `restore` to it) but are + /// memory-isolated from each other. + #[test] + fn arc_clone_isolation_and_restore_compat() { + let mut sbox = make_sandbox(); + sbox.call::("AddToStatic", 3i32).unwrap(); + let snapshot = sbox.snapshot().unwrap(); + + let mut a = + MultiUseSandbox::from_snapshot(snapshot.clone(), HostFunctions::default(), None) + .unwrap(); + let mut b = + MultiUseSandbox::from_snapshot(snapshot.clone(), HostFunctions::default(), None) + .unwrap(); + assert_eq!(a.call::("GetStatic", ()).unwrap(), 3); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 3); + + a.call::("AddToStatic", 7i32).unwrap(); + assert_eq!(a.call::("GetStatic", ()).unwrap(), 10); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 3); + + a.restore(snapshot.clone()).unwrap(); + b.restore(snapshot).unwrap(); + assert_eq!(a.call::("GetStatic", ()).unwrap(), 3); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 3); + } + + #[test] + fn accepts_matching_host_functions() { + let mut sbox = make_sandbox_with_add(); + sbox.call::("AddToStatic", 5i32).unwrap(); + let snap = sbox.snapshot().unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(snap, host_funcs_with_matching_add(), None).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 5); + } + + #[test] + fn rejects_missing_host_function() { + let mut sbox = make_sandbox_with_add(); + let snap = sbox.snapshot().unwrap(); + let err = MultiUseSandbox::from_snapshot(snap, HostFunctions::default(), None) + .expect_err("missing `Add` must be rejected"); + let msg = format!("{}", err); + assert!(msg.contains("Add"), "got: {}", msg); + } + + #[test] + fn rejects_signature_mismatch() { + let mut sbox = make_sandbox_with_add(); + let snap = sbox.snapshot().unwrap(); + let mut hf = HostFunctions::default(); + hf.register_host_function("Add", |a: String, b: String| Ok(format!("{a}{b}"))) + .unwrap(); + let err = MultiUseSandbox::from_snapshot(snap, hf, None) + .expect_err("signature mismatch on `Add` must be rejected"); + let msg = format!("{}", err); + assert!(msg.contains("Add"), "got: {}", msg); + } + + /// Supplied host-function set may be a strict superset of the + /// snapshot's required set. + #[test] + fn accepts_extra_host_functions() { + let mut sbox = make_sandbox_with_add(); + sbox.call::("AddToStatic", 9i32).unwrap(); + let snap = sbox.snapshot().unwrap(); + let mut hf = host_funcs_with_matching_add(); + hf.register_host_function("Mul", |a: i32, b: i32| Ok(a * b)) + .unwrap(); + let mut sbox2 = MultiUseSandbox::from_snapshot(snap, hf, None).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 9); + } + + /// A sandbox built via `from_snapshot` can itself be snapshotted + /// and restored, and its snapshots are restore-compatible with it. + #[test] + fn re_snapshot_after_from_snapshot() { + let mut sbox = make_sandbox(); + sbox.call::("AddToStatic", 4i32).unwrap(); + let snap1 = sbox.snapshot().unwrap(); + + let mut sbox2 = + MultiUseSandbox::from_snapshot(snap1, HostFunctions::default(), None).unwrap(); + sbox2.call::("AddToStatic", 6i32).unwrap(); + let snap2 = sbox2.snapshot().unwrap(); + + sbox2.call::("AddToStatic", 100i32).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 110); + + sbox2.restore(snap2.clone()).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 10); + + let mut sbox3 = + MultiUseSandbox::from_snapshot(snap2, HostFunctions::default(), None).unwrap(); + assert_eq!(sbox3.call::("GetStatic", ()).unwrap(), 10); + } + + /// The host function closure supplied to `from_snapshot` (not the + /// original sandbox's closure) is the one invoked at runtime. + #[test] + fn supplied_host_function_is_callable() { + let path = simple_guest_as_string().unwrap(); + let mut u = UninitializedSandbox::new(GuestBinary::FilePath(path), None).unwrap(); + u.register_host_function("Echo42", || Ok(1i64)).unwrap(); + let mut sbox = u.evolve().unwrap(); + let snap = sbox.snapshot().unwrap(); + + let mut hf = HostFunctions::default(); + hf.register_host_function("Echo42", || Ok(42i64)).unwrap(); + let mut sbox2 = MultiUseSandbox::from_snapshot(snap, hf, None).unwrap(); + + let got: i64 = sbox2 + .call( + "CallGivenParamlessHostFuncThatReturnsI64", + "Echo42".to_string(), + ) + .unwrap(); + assert_eq!(got, 42); + } + + /// Pre-init snapshots record no required host functions, so any + /// `HostFunctions` set is accepted. + #[test] + fn pre_init_snapshot_accepts_arbitrary_host_functions() { + let path = simple_guest_as_string().unwrap(); + let snap = + Snapshot::from_env(GuestBinary::FilePath(path), SandboxConfiguration::default()) + .unwrap(); + let mut hf = HostFunctions::default(); + hf.register_host_function("Unrelated", |a: i32| Ok(a + 1)) + .unwrap(); + let mut sbox = MultiUseSandbox::from_snapshot(Arc::new(snap), hf, None).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + } + + /// Snapshots taken from a sandbox built via `from_snapshot` + /// must continue the generation counter of the snapshot they + /// were constructed from, matching `restore`. + #[test] + fn snapshot_generation_propagates() { + let mut sbox = make_sandbox(); + sbox.call::("AddToStatic", 1i32).unwrap(); + let snap1 = sbox.snapshot().unwrap(); + let gen1 = snap1.snapshot_generation(); + sbox.call::("AddToStatic", 1i32).unwrap(); + let snap2 = sbox.snapshot().unwrap(); + let gen2 = snap2.snapshot_generation(); + assert_eq!(gen2, gen1 + 1); + + let mut sbox2 = + MultiUseSandbox::from_snapshot(snap2, HostFunctions::default(), None).unwrap(); + sbox2.call::("AddToStatic", 1i32).unwrap(); + let snap3 = sbox2.snapshot().unwrap(); + assert_eq!(snap3.snapshot_generation(), gen2 + 1); + } + + /// Registering a host function on an already-evolved + /// `MultiUseSandbox` must invalidate its cached snapshot, so + /// that the next `snapshot()` reflects the new required + /// host-function set. + #[test] + fn late_register_invalidates_snapshot_cache() { + let mut sbox = make_sandbox(); + // Force a cached snapshot to exist. + let _ = sbox.snapshot().unwrap(); + + sbox.register_host_function("Echo42", || Ok(42i64)).unwrap(); + + // The next snapshot must include `Echo42` as a required + // host function, so building a sandbox from it without + // `Echo42` must fail. + let snap = sbox.snapshot().unwrap(); + let err = MultiUseSandbox::from_snapshot(snap, HostFunctions::default(), None) + .expect_err("late-registered `Echo42` must be required by the new snapshot"); + let msg = format!("{}", err); + assert!(msg.contains("Echo42"), "got: {}", msg); + } + } } From d5fcf2df510862b839ea6d98f80fb53df702b8d7 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:51:09 -0700 Subject: [PATCH 08/12] Add file backed ReadonlySharedMemory Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- .../src/hypervisor/hyperlight_vm/x86_64.rs | 2 +- src/hyperlight_host/src/mem/layout.rs | 20 +- src/hyperlight_host/src/mem/memory_region.rs | 4 +- src/hyperlight_host/src/mem/shared_mem.rs | 533 +++++++++++++++++- .../src/sandbox/snapshot/mod.rs | 15 +- 5 files changed, 537 insertions(+), 37 deletions(-) diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs index 6feff13f1..791a4e976 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs @@ -1497,7 +1497,7 @@ mod tests { [layout.get_guest_code_offset()..layout.get_guest_code_offset() + code.len()] .copy_from_slice(code); layout.write_peb(&mut snapshot_contents).unwrap(); - let ro_mem = ReadonlySharedMemory::from_bytes(&snapshot_contents).unwrap(); + let ro_mem = ReadonlySharedMemory::from_bytes(&snapshot_contents, None).unwrap(); let scratch_mem = ExclusiveSharedMemory::new(config.get_scratch_size()).unwrap(); let mem_mgr = SandboxMemoryManager::new( diff --git a/src/hyperlight_host/src/mem/layout.rs b/src/hyperlight_host/src/mem/layout.rs index 8f1790055..97ff33ec2 100644 --- a/src/hyperlight_host/src/mem/layout.rs +++ b/src/hyperlight_host/src/mem/layout.rs @@ -232,9 +232,16 @@ pub(crate) struct SandboxMemoryLayout { pub(crate) init_data_permissions: Option, /// The size of the scratch region in physical memory. pub(crate) scratch_size: usize, - /// The size of the snapshot region in physical memory. + /// Size of the guest-visible prefix of the snapshot blob. + /// This is the number of bytes from the start of the blob that + /// the hypervisor exposes to the guest at `BASE_ADDRESS`. The + /// page-table tail (`pt_size`) sits after it in the blob and + /// the host mapping, and is copied into the scratch region on + /// restore (see `SandboxMemoryManager::update_scratch_bookkeeping`). + /// Invariant: `blob_size == snapshot_size + pt_size`. pub(crate) snapshot_size: usize, - /// The size of the page tables (None if not yet set). + /// Size of the page-table tail appended to the snapshot blob. + /// `None` during construction before page tables are built. pub(crate) pt_size: Option, } @@ -497,7 +504,12 @@ impl SandboxMemoryLayout { } } - /// Sets the size of the memory region used for page tables + /// Record the size of the page-table tail appended to the + /// snapshot blob. The PT bytes live at the end of the blob and + /// the host mapping, outside the guest mapping of the snapshot + /// region, and are copied into the scratch region on restore. + /// `snapshot_size` (the guest-visible prefix of the blob) is an + /// independent field and must be set separately. #[instrument(skip_all, parent = Span::current(), level= "Trace")] pub(crate) fn set_pt_size(&mut self, size: usize) -> Result<()> { let min_fixed_scratch = hyperlight_common::layout::min_scratch_size( @@ -508,8 +520,6 @@ impl SandboxMemoryLayout { if self.scratch_size < min_scratch { return Err(MemoryRequestTooSmall(self.scratch_size, min_scratch)); } - let old_pt_size = self.pt_size.unwrap_or(0); - self.snapshot_size = self.snapshot_size - old_pt_size + size; self.pt_size = Some(size); Ok(()) } diff --git a/src/hyperlight_host/src/mem/memory_region.rs b/src/hyperlight_host/src/mem/memory_region.rs index 615fe9cac..de3c83a20 100644 --- a/src/hyperlight_host/src/mem/memory_region.rs +++ b/src/hyperlight_host/src/mem/memory_region.rs @@ -158,7 +158,9 @@ impl MemoryRegionType { /// shared memory mapping with guard pages. pub fn surrogate_mapping(&self) -> SurrogateMapping { match self { - MemoryRegionType::MappedFile => SurrogateMapping::ReadOnlyFile, + MemoryRegionType::MappedFile | MemoryRegionType::Snapshot => { + SurrogateMapping::ReadOnlyFile + } _ => SurrogateMapping::SandboxMemory, } } diff --git a/src/hyperlight_host/src/mem/shared_mem.rs b/src/hyperlight_host/src/mem/shared_mem.rs index 5f975f605..6b248fc9a 100644 --- a/src/hyperlight_host/src/mem/shared_mem.rs +++ b/src/hyperlight_host/src/mem/shared_mem.rs @@ -30,8 +30,11 @@ use windows::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}; use windows::Win32::System::Memory::PAGE_READWRITE; #[cfg(target_os = "windows")] use windows::Win32::System::Memory::{ - CreateFileMappingA, FILE_MAP_ALL_ACCESS, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, - PAGE_NOACCESS, PAGE_PROTECTION_FLAGS, UnmapViewOfFile, VirtualProtect, + CreateFileMappingA, FILE_MAP_ALL_ACCESS, MEM_PRESERVE_PLACEHOLDER, MEM_RELEASE, + MEM_REPLACE_PLACEHOLDER, MEM_RESERVE, MEM_RESERVE_PLACEHOLDER, MEMORY_MAPPED_VIEW_ADDRESS, + MapViewOfFile, MapViewOfFile3, PAGE_NOACCESS, PAGE_PROTECTION_FLAGS, PAGE_READONLY, + UnmapViewOfFile, VIRTUAL_ALLOCATION_TYPE, VIRTUAL_FREE_TYPE, VirtualAlloc2, VirtualFree, + VirtualProtect, }; #[cfg(target_os = "windows")] use windows::core::PCSTR; @@ -87,6 +90,25 @@ macro_rules! generate_writer { }; } +/// On Windows, [`HostMapping`]s come in two flavours that need +/// different teardown sequences. +#[cfg(target_os = "windows")] +#[derive(Debug, Clone, Copy)] +enum WinTeardown { + /// Created via `CreateFileMapping + MapViewOfFile`. The view base + /// is exactly `ptr` and the view extent is `size`. Drop calls + /// `UnmapViewOfFile(ptr)` and then `CloseHandle(handle)`. + Mapped, + /// Created via `VirtualAlloc2 + (split) + MapViewOfFile3`. A view + /// of size `size - 2 * PAGE_SIZE` lives at `ptr + PAGE_SIZE`, + /// inside a `[guard][view][guard]` layout that spans + /// `[ptr, ptr + size)`. After the placeholder split each of the + /// three slots is an independent allocation. Drop reconstructs + /// a [`MappedView`] for the middle and a [`Placeholder`] for + /// each guard so each slot is torn down by its own RAII type. + Placeholder, +} + /// A representation of a host mapping of a shared memory region, /// which will be released when this structure is Drop'd. This is not /// individually Clone (since it holds ownership of the mapping), or @@ -97,6 +119,8 @@ pub struct HostMapping { size: usize, #[cfg(target_os = "windows")] handle: HANDLE, + #[cfg(target_os = "windows")] + teardown: WinTeardown, } impl Drop for HostMapping { @@ -110,19 +134,45 @@ impl Drop for HostMapping { } #[cfg(target_os = "windows")] fn drop(&mut self) { - let mem_mapped_address = MEMORY_MAPPED_VIEW_ADDRESS { - Value: self.ptr as *mut c_void, - }; - if let Err(e) = unsafe { UnmapViewOfFile(mem_mapped_address) } { - tracing::error!( - "Failed to drop HostMapping (UnmapViewOfFile failed): {:?}", - e - ); + match self.teardown { + WinTeardown::Mapped => { + let mem_mapped_address = MEMORY_MAPPED_VIEW_ADDRESS { + Value: self.ptr as *mut c_void, + }; + if let Err(e) = unsafe { UnmapViewOfFile(mem_mapped_address) } { + tracing::error!( + "Failed to drop HostMapping (UnmapViewOfFile failed): {:?}", + e + ); + } + } + WinTeardown::Placeholder => { + // Reconstruct per-slot owners. Each Drop releases + // its own slot: + // * `MappedView` -> `UnmapViewOfFile` releases + // the middle view's address range. + // * `Placeholder` -> `VirtualFree(MEM_RELEASE)` + // releases its placeholder slot. + // The three slots are independent allocations, so + // drop order does not matter. + let _view = MappedView { + addr: self.ptr.wrapping_add(PAGE_SIZE_USIZE) as *mut c_void, + len: self.size - 2 * PAGE_SIZE_USIZE, + }; + let _trailing = Placeholder { + addr: self.ptr.wrapping_add(self.size - PAGE_SIZE_USIZE) as *mut c_void, + size: PAGE_SIZE_USIZE, + }; + let _leading = Placeholder { + addr: self.ptr as *mut c_void, + size: PAGE_SIZE_USIZE, + }; + } } let file_handle: HANDLE = self.handle; if let Err(e) = unsafe { CloseHandle(file_handle) } { - tracing::error!("Failed to drop HostMapping (CloseHandle failed): {:?}", e); + tracing::error!("Failed to drop HostMapping (CloseHandle failed): {:?}", e); } } } @@ -539,6 +589,7 @@ impl ExclusiveSharedMemory { ptr: addr.Value as *mut u8, size: total_size, handle, + teardown: WinTeardown::Mapped, }), }) } @@ -2026,25 +2077,243 @@ pub struct ReadonlySharedMemory { unsafe impl Send for ReadonlySharedMemory {} unsafe impl Sync for ReadonlySharedMemory {} -impl ReadonlySharedMemory { - pub(crate) fn from_bytes(contents: &[u8]) -> Result { - let mut anon = ExclusiveSharedMemory::new(contents.len())?; - anon.copy_from_slice(contents, 0)?; - Ok(ReadonlySharedMemory { - region: anon.region, - guest_mapped_size: None, - }) +/// RAII guard for an `mmap` reservation. Calls `munmap` on drop. +#[cfg(target_os = "linux")] +struct MmapGuard { + base: *mut c_void, + len: usize, +} + +#[cfg(target_os = "linux")] +impl MmapGuard { + /// Consume the guard without unmapping and return the base pointer. + fn into_raw(self) -> *mut c_void { + std::mem::ManuallyDrop::new(self).base } +} - pub(crate) fn from_bytes_with_mapped_size( - contents: &[u8], - guest_mapped_size: usize, - ) -> Result { +#[cfg(target_os = "linux")] +impl Drop for MmapGuard { + fn drop(&mut self) { + unsafe { + if libc::munmap(self.base, self.len) != 0 { + tracing::error!( + "MmapGuard::drop: munmap failed: {:?}", + std::io::Error::last_os_error() + ); + } + } + } +} + +/// A `VirtualAlloc2(MEM_RESERVE_PLACEHOLDER)` placeholder slot. +/// +/// `Placeholder` is a linear, owning handle to a single placeholder +/// allocation. State transitions consume `self`, so: +/// +/// * a failure between `reserve` and the final transfer to +/// [`HostMapping`] tears the placeholder down automatically via +/// `Drop`, +/// * after a successful `split_front` or `map_file_view`, the +/// parent value is forgotten and only the new owners (two child +/// placeholders or a [`MappedView`]) can be released. +/// +/// On `Drop`, the slot is released with +/// `VirtualFree(addr, 0, MEM_RELEASE)`. Each post-split slot is an +/// independent allocation, so each must be owned by its own +/// `Placeholder`. +#[cfg(target_os = "windows")] +struct Placeholder { + addr: *mut c_void, + size: usize, +} + +#[cfg(target_os = "windows")] +impl Placeholder { + /// Reserve a fresh placeholder of `size` bytes at an OS-chosen + /// address. + unsafe fn reserve(size: usize) -> Result { + let addr = unsafe { + VirtualAlloc2( + None, + None, + size, + VIRTUAL_ALLOCATION_TYPE(MEM_RESERVE.0 | MEM_RESERVE_PLACEHOLDER.0), + PAGE_NOACCESS.0, + None, + ) + }; + if addr.is_null() { + log_then_return!(HyperlightError::MemoryAllocationFailed( + Error::last_os_error().raw_os_error() + )); + } + Ok(Placeholder { addr, size }) + } + + /// Split `front_size` bytes off the front. Consumes `self` and + /// returns two adjacent placeholders that together cover the + /// same range. Releases the original via `Drop` if the underlying + /// `VirtualFree` split call fails. + unsafe fn split_front(self, front_size: usize) -> Result<(Placeholder, Placeholder)> { + debug_assert!(front_size > 0 && front_size < self.size); + debug_assert!(front_size.is_multiple_of(PAGE_SIZE_USIZE)); + // `MEM_RELEASE | MEM_PRESERVE_PLACEHOLDER` is the Win32 idiom for + // splitting one placeholder into two. Despite the flag name, no + // memory is released. Both ranges remain reserved as independent + // placeholders. + if let Err(e) = unsafe { + VirtualFree( + self.addr, + front_size, + VIRTUAL_FREE_TYPE(MEM_RELEASE.0 | MEM_PRESERVE_PLACEHOLDER.0), + ) + } { + // `self` drops here, releasing the unsplit reservation. + log_then_return!(WindowsAPIError(e.clone())); + } + let addr = self.addr; + let total = self.size; + // The single allocation is now two adjacent allocations. + // Forget the parent so its `Drop` does not try to release + // them as one. + std::mem::forget(self); + let front = Placeholder { + addr, + size: front_size, + }; + let back = Placeholder { + addr: unsafe { (addr as *mut u8).add(front_size) as *mut c_void }, + size: total - front_size, + }; + Ok((front, back)) + } + + /// Carve the placeholder into three adjacent slots of sizes + /// `front_size`, `middle_size`, and `self.size - front_size - + /// middle_size`. Consumes `self`. + unsafe fn split_into_three( + self, + front_size: usize, + middle_size: usize, + ) -> Result<(Placeholder, Placeholder, Placeholder)> { + let (front, rest) = unsafe { self.split_front(front_size) }?; + let (middle, back) = unsafe { rest.split_front(middle_size) }?; + Ok((front, middle, back)) + } + + /// Replace the placeholder with a read-only view of `section` + /// covering exactly the placeholder's range. Consumes `self` + /// and yields a [`MappedView`] that owns the slot. + unsafe fn map_file_view(self, section: HANDLE) -> Result { + let mapped = unsafe { + MapViewOfFile3( + section, + None, + Some(self.addr), + 0, + self.size, + MEM_REPLACE_PLACEHOLDER, + PAGE_READONLY.0, + None, + ) + }; + if mapped.Value.is_null() { + // `self` drops here, releasing the placeholder. + log_then_return!(HyperlightError::MemoryAllocationFailed( + Error::last_os_error().raw_os_error() + )); + } + let addr = self.addr; + let len = self.size; + std::mem::forget(self); + Ok(MappedView { addr, len }) + } +} + +#[cfg(target_os = "windows")] +impl Drop for Placeholder { + fn drop(&mut self) { + if let Err(e) = unsafe { VirtualFree(self.addr, 0, VIRTUAL_FREE_TYPE(MEM_RELEASE.0)) } { + tracing::error!( + "Placeholder::drop(addr={:?}, size={}) VirtualFree failed: {:?}", + self.addr, + self.size, + e + ); + } + } +} + +/// A read-only file-mapping view that occupies one former placeholder +/// slot. On `Drop`, the view is unmapped with `UnmapViewOfFile`, +/// which releases its address range back to the OS (see the +/// `VirtualAlloc2` ring-buffer example in MSDN). +#[cfg(target_os = "windows")] +struct MappedView { + addr: *mut c_void, + len: usize, +} + +#[cfg(target_os = "windows")] +impl Drop for MappedView { + fn drop(&mut self) { + let view = MEMORY_MAPPED_VIEW_ADDRESS { Value: self.addr }; + if let Err(e) = unsafe { UnmapViewOfFile(view) } { + tracing::error!( + "MappedView::drop(addr={:?}, len={}) UnmapViewOfFile failed: {:?}", + self.addr, + self.len, + e + ); + } + } +} + +/// RAII guard for a Win32 `HANDLE`. Calls `CloseHandle` on drop. +#[cfg(target_os = "windows")] +struct HandleGuard(HANDLE); + +#[cfg(target_os = "windows")] +impl HandleGuard { + /// Consume the guard without closing and return the raw handle. + fn into_raw(self) -> HANDLE { + std::mem::ManuallyDrop::new(self).0 + } +} + +#[cfg(target_os = "windows")] +impl Drop for HandleGuard { + fn drop(&mut self) { + unsafe { + if let Err(e) = CloseHandle(self.0) { + tracing::error!("HandleGuard::drop: CloseHandle failed: {:?}", e); + } + } + } +} + +impl ReadonlySharedMemory { + /// Create a `ReadonlySharedMemory` from an in-memory byte slice. + /// + /// `guest_mapped_size` is the number of bytes from the start of + /// the blob that should be exposed to the guest. `None` means + /// expose the full blob. + pub(crate) fn from_bytes(contents: &[u8], guest_mapped_size: Option) -> Result { + if let Some(gms) = guest_mapped_size + && gms > contents.len() + { + return Err(new_error!( + "guest_mapped_size {} exceeds blob length {}", + gms, + contents.len() + )); + } let mut anon = ExclusiveSharedMemory::new(contents.len())?; anon.copy_from_slice(contents, 0)?; Ok(ReadonlySharedMemory { region: anon.region, - guest_mapped_size: Some(guest_mapped_size), + guest_mapped_size, }) } @@ -2055,6 +2324,186 @@ impl ReadonlySharedMemory { self.guest_mapped_size.unwrap_or_else(|| self.mem_size()) } + /// Create a `ReadonlySharedMemory` backed by a file on disk. + /// + /// The entire file is treated as a raw memory blob: its contents + /// are exposed via `base_ptr()` and `mem_size()`, surrounded on + /// the host by `PAGE_SIZE` guard pages that are not part of the + /// file. + /// + /// The file's length must be a non-zero multiple of `PAGE_SIZE`. + /// If `guest_mapped_size` is set, it must be a non-zero multiple + /// of `PAGE_SIZE` no greater than the file's length. + pub(crate) fn from_file( + file: &std::fs::File, + guest_mapped_size: Option, + ) -> Result { + let len: usize = file + .metadata() + .map_err(|e| new_error!("Failed to read file metadata: {}", e))? + .len() + .try_into() + .map_err(|_| new_error!("File length exceeds usize::MAX"))?; + + if len == 0 { + return Err(new_error!( + "Cannot create file-backed shared memory with size 0" + )); + } + + if !len.is_multiple_of(PAGE_SIZE_USIZE) { + return Err(new_error!( + "file length {} must be a multiple of PAGE_SIZE", + len + )); + } + + if let Some(gms) = guest_mapped_size + && (gms == 0 || gms > len || !gms.is_multiple_of(PAGE_SIZE_USIZE)) + { + return Err(new_error!( + "guest_mapped_size {} must be a non-zero multiple of PAGE_SIZE no greater than file length {}", + gms, + len + )); + } + + let region = Self::map_file(file, len)?; + Ok(ReadonlySharedMemory { + region, + guest_mapped_size, + }) + } + + /// Linux: reserve `[guard][blob][guard]` as one anonymous + /// `PROT_NONE` mapping, then `MAP_FIXED` the file over the + /// middle slot. + #[cfg(target_os = "linux")] + fn map_file(file: &std::fs::File, len: usize) -> Result> { + use std::os::unix::io::AsRawFd; + + use libc::{ + MAP_ANONYMOUS, MAP_FAILED, MAP_FIXED, MAP_NORESERVE, MAP_PRIVATE, PROT_NONE, PROT_READ, + PROT_WRITE, mmap, off_t, size_t, + }; + + let total_size = len.checked_add(2 * PAGE_SIZE_USIZE).ok_or_else(|| { + new_error!("Memory required for file-backed mapping exceeded usize::MAX") + })?; + + let fd = file.as_raw_fd(); + + // Allocate the full region (guard + usable + guard) as anonymous. + let base = unsafe { + mmap( + null_mut(), + total_size as size_t, + PROT_NONE, + MAP_ANONYMOUS | MAP_PRIVATE | MAP_NORESERVE, + -1, + 0 as off_t, + ) + }; + if base == MAP_FAILED { + return Err(HyperlightError::MmapFailed( + std::io::Error::last_os_error().raw_os_error(), + )); + } + // Hand the reservation to a guard so any early return releases it. + let reservation = MmapGuard { + base, + len: total_size, + }; + + // Map the file content over the usable portion (between guard pages). + // PROT_READ | PROT_WRITE: KVM/MSHV require writable host mappings + // to handle copy-on-write page faults from the guest. + // MAP_PRIVATE: writes go to private copies, not the file. + let usable_ptr = unsafe { (base as *mut u8).add(PAGE_SIZE_USIZE) }; + let mapped = unsafe { + mmap( + usable_ptr as *mut c_void, + len as size_t, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_FIXED | MAP_NORESERVE, + fd, + 0 as off_t, + ) + }; + if mapped == MAP_FAILED { + return Err(HyperlightError::MmapFailed( + std::io::Error::last_os_error().raw_os_error(), + )); + } + + // Guard pages at base and base+total_size-PAGE_SIZE are already + // PROT_NONE from the anonymous mapping; MAP_FIXED only replaced + // the middle portion. + + #[allow(clippy::arc_with_non_send_sync)] + Ok(Arc::new(HostMapping { + ptr: reservation.into_raw() as *mut u8, + size: total_size, + })) + } + + /// Windows: reserve `[guard][blob][guard]` as one + /// `VirtualAlloc2` placeholder, split the middle slot out, and + /// `MapViewOfFile3` the file over the middle slot. + #[cfg(target_os = "windows")] + fn map_file(file: &std::fs::File, len: usize) -> Result> { + use std::os::windows::io::AsRawHandle; + + let total_size = len.checked_add(2 * PAGE_SIZE_USIZE).ok_or_else(|| { + new_error!("Memory required for file-backed mapping exceeded usize::MAX") + })?; + + let file_handle = HANDLE(file.as_raw_handle()); + + // 1. Reserve [guard][view][guard] as one placeholder. + let whole = unsafe { Placeholder::reserve(total_size) }?; + + // 2. Carve into three adjacent placeholders. A failure + // after this point owns exactly the right slots and + // cleans them up via `Drop`. + let (leading, middle, trailing) = unsafe { whole.split_into_three(PAGE_SIZE_USIZE, len) }?; + + // 3. Create a read-only section covering the entire file. + let raw_handle = + unsafe { CreateFileMappingA(file_handle, None, PAGE_READONLY, 0, 0, PCSTR::null()) }?; + if raw_handle.is_invalid() { + log_then_return!(HyperlightError::MemoryAllocationFailed( + Error::last_os_error().raw_os_error() + )); + } + let section = HandleGuard(raw_handle); + + // 4. Replace the middle placeholder with a view of the file. + let view = unsafe { middle.map_file_view(raw_handle) }?; + + // Success. Transfer ownership of every slot into + // `HostMapping`; its `Drop` reconstructs the per-slot + // owners and releases them. + let base = leading.addr as *mut u8; + debug_assert_eq!(view.addr, unsafe { + base.add(PAGE_SIZE_USIZE) as *mut c_void + }); + debug_assert_eq!(trailing.addr, unsafe { + base.add(PAGE_SIZE_USIZE + len) as *mut c_void + }); + std::mem::forget(leading); + std::mem::forget(view); + std::mem::forget(trailing); + + #[allow(clippy::arc_with_non_send_sync)] + Ok(Arc::new(HostMapping { + ptr: base, + size: total_size, + handle: section.into_raw(), + teardown: WinTeardown::Placeholder, + })) + } + pub(crate) fn as_slice(&self) -> &[u8] { unsafe { std::slice::from_raw_parts(self.base_ptr(), self.mem_size()) } } @@ -2098,6 +2547,42 @@ impl SharedMemory for ReadonlySharedMemory { fn region(&self) -> &HostMapping { &self.region } + // The trait defaults for `base_addr`, `base_ptr`, and `mem_size` + // already do the right thing: every `ReadonlySharedMemory` is + // backed by a `[guard][blob][guard]` host region, so the blob + // starts at `region.ptr + PAGE_SIZE` and runs for + // `region.size - 2 * PAGE_SIZE` bytes. + // + // `host_region_base` differs per Windows mapping flavour: + // + // * `WinTeardown::Mapped` (in-memory `from_bytes*`, backed by + // an anonymous `CreateFileMapping`). The file mapping section + // spans the entire `[guard][blob][guard]` reservation, so the + // surrogate maps the whole section and we hand out + // `handle_base = region.ptr`, `handle_size = region.size`, + // `offset = PAGE_SIZE`. Same as the trait default. + // + // * `WinTeardown::Placeholder` (`from_file`). The file mapping + // section covers only the blob, with the host's guard pages + // held as `VirtualAlloc2` placeholders in the surrounding + // address space. Expose only the blob to the surrogate. + #[cfg(windows)] + fn host_region_base(&self) -> ::HostBaseType { + match self.region().teardown { + WinTeardown::Mapped => super::memory_region::HostRegionBase { + from_handle: self.region().handle.into(), + handle_base: self.region().ptr as usize, + handle_size: self.region().size, + offset: PAGE_SIZE_USIZE, + }, + WinTeardown::Placeholder => super::memory_region::HostRegionBase { + from_handle: self.region().handle.into(), + handle_base: self.base_ptr() as usize, + handle_size: self.mem_size(), + offset: 0, + }, + } + } // There's no way to get exclusive (and therefore writable) access // to a ReadonlySharedMemory. fn with_exclusivity T>( diff --git a/src/hyperlight_host/src/sandbox/snapshot/mod.rs b/src/hyperlight_host/src/sandbox/snapshot/mod.rs index 4954645b5..1c816b20e 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/mod.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/mod.rs @@ -422,7 +422,7 @@ impl Snapshot { Ok(Self { sandbox_id: SANDBOX_CONFIGURATION_COUNTER.fetch_add(1, Ordering::Relaxed), - memory: ReadonlySharedMemory::from_bytes(&memory)?, + memory: ReadonlySharedMemory::from_bytes(&memory, Some(layout.snapshot_size))?, layout, regions: extra_regions, load_info, @@ -594,9 +594,12 @@ impl Snapshot { Ok::<_, crate::HyperlightError>(snapshot_memory) }) })???; - // Only map the data portion into guest PA space. The PT tail - // must stay out of the KVM slot to avoid overlapping with - // map_file_cow regions that sit right after the snapshot. + // Only the data prefix is exposed to the guest. The PT tail + // sits past it in the host mapping and is copied into the + // scratch region on restore. Keeping it out of the guest + // mapping of the snapshot region avoids overlap with + // `map_file_cow` regions installed immediately after the + // snapshot in guest PA space. let guest_visible_size = memory.len() - layout.get_pt_size(); debug_assert!(guest_visible_size.is_multiple_of(PAGE_SIZE)); layout.set_snapshot_size(guest_visible_size); @@ -614,7 +617,7 @@ impl Snapshot { Ok(Self { sandbox_id, layout, - memory: ReadonlySharedMemory::from_bytes_with_mapped_size(&memory, guest_visible_size)?, + memory: ReadonlySharedMemory::from_bytes(&memory, Some(guest_visible_size))?, regions, load_info, hash, @@ -783,7 +786,7 @@ mod tests { let mut snapshot_mem = vec![0u8; PAGE_SIZE + pt_bytes.len()]; snapshot_mem[0..PAGE_SIZE].copy_from_slice(contents); snapshot_mem[PAGE_SIZE..].copy_from_slice(&pt_bytes); - ReadonlySharedMemory::from_bytes(&snapshot_mem) + ReadonlySharedMemory::from_bytes(&snapshot_mem, None) .unwrap() .to_mgr_snapshot_mem() .unwrap() From 8c840fef195436de0d30f596e78b0d3c590fd11f Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Thu, 14 May 2026 14:03:35 -0700 Subject: [PATCH 09/12] Remove blake3 hash from Snapshot The blake3 `hash` field and helper provided a deterministic identity for in-memory and persisted Snapshots. The forthcoming OCI snapshot format relies on OCI image digests for content addressability and integrity, so the manual hash is redundant. Drop the field and its computation. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- .../src/sandbox/snapshot/mod.rs | 54 ------------------- 1 file changed, 54 deletions(-) diff --git a/src/hyperlight_host/src/sandbox/snapshot/mod.rs b/src/hyperlight_host/src/sandbox/snapshot/mod.rs index 1c816b20e..f0551e25c 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/mod.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/mod.rs @@ -25,7 +25,6 @@ use hyperlight_common::vmem::{ }; use tracing::{Span, instrument}; -use crate::HyperlightError::MemoryRegionSizeMismatch; use crate::Result; use crate::hypervisor::regs::CommonSpecialRegisters; use crate::mem::exe::{ExeInfo, LoadInfo}; @@ -89,14 +88,6 @@ pub struct Snapshot { /// things like persisting a snapshot and reloading it are likely /// to destroy this information. load_info: LoadInfo, - /// The hash of the other portions of the snapshot. Morally, this - /// is just a memoization cache for [`hash`], below, but it is not - /// a [`std::sync::OnceLock`] because it may be persisted to disk - /// without being recomputed on load. - /// - /// It is not a [`blake3::Hash`] because we do not presently - /// require constant-time equality checking - hash: [u8; 32], /// The address of the top of the guest stack stack_top_gva: u64, @@ -160,41 +151,6 @@ impl hyperlight_common::vmem::TableReadOps for Snapshot { } } -/// Compute a deterministic hash of a snapshot. -/// -/// This does not include the load info from the snapshot, because -/// that is only used for debugging builds. -fn hash(memory: &[u8], regions: &[MemoryRegion]) -> Result<[u8; 32]> { - let mut hasher = blake3::Hasher::new(); - hasher.update(memory); - for rgn in regions { - hasher.update(&usize::to_le_bytes(rgn.guest_region.start)); - let guest_len = rgn.guest_region.end - rgn.guest_region.start; - #[allow(clippy::useless_conversion)] - let host_start_addr: usize = rgn.host_region.start.into(); - #[allow(clippy::useless_conversion)] - let host_end_addr: usize = rgn.host_region.end.into(); - hasher.update(&usize::to_le_bytes(host_start_addr)); - let host_len = host_end_addr - host_start_addr; - if guest_len != host_len { - return Err(MemoryRegionSizeMismatch( - host_len, - guest_len, - format!("{:?}", rgn), - )); - } - // Ignore [`MemoryRegion::region_type`], since it is extra - // information for debugging rather than a core part of the - // identity of the snapshot/workload. - hasher.update(&usize::to_le_bytes(guest_len)); - hasher.update(&u32::to_le_bytes(rgn.flags.bits())); - } - // Ignore [`load_info`], since it is extra information for - // debugging rather than a core part of the identity of the - // snapshot/workload. - Ok(hasher.finalize().into()) -} - pub(crate) fn access_gpa<'a>( snap: &'a [u8], scratch: &'a [u8], @@ -418,7 +374,6 @@ impl Snapshot { + 1; let extra_regions = Vec::new(); - let hash = hash(&memory, &extra_regions)?; Ok(Self { sandbox_id: SANDBOX_CONFIGURATION_COUNTER.fetch_add(1, Ordering::Relaxed), @@ -426,7 +381,6 @@ impl Snapshot { layout, regions: extra_regions, load_info, - hash, stack_top_gva: exn_stack_top_gva, sregs: None, entrypoint: NextAction::Initialise(load_addr + entrypoint_va - base_va), @@ -613,14 +567,12 @@ impl Snapshot { // PAs overlap the old region PAs. let regions: Vec = Vec::new(); - let hash = hash(&memory, ®ions)?; Ok(Self { sandbox_id, layout, memory: ReadonlySharedMemory::from_bytes(&memory, Some(guest_visible_size))?, regions, load_info, - hash, stack_top_gva, sregs: Some(sregs), entrypoint, @@ -740,12 +692,6 @@ impl Snapshot { } } -impl PartialEq for Snapshot { - fn eq(&self, other: &Snapshot) -> bool { - self.hash == other.hash - } -} - #[cfg(test)] #[cfg(not(feature = "i686-guest"))] mod tests { From 0a726bf12d892d4fd2a16dbd76b7e271ff4b3bcd Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Fri, 1 May 2026 16:01:13 -0700 Subject: [PATCH 10/12] Add OCI snapshot persistence Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- Cargo.lock | 186 +++ src/hyperlight_host/Cargo.toml | 5 +- .../src/sandbox/initialized_multi_use.rs | 14 + .../src/sandbox/snapshot/file.rs | 1392 +++++++++++++++++ .../src/sandbox/snapshot/mod.rs | 3 + 5 files changed, 1599 insertions(+), 1 deletion(-) create mode 100644 src/hyperlight_host/src/sandbox/snapshot/file.rs diff --git a/Cargo.lock b/Cargo.lock index 86b87ae74..3c0c76176 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -530,6 +530,27 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -730,6 +751,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -741,6 +797,37 @@ dependencies = [ "syn", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -1154,6 +1241,18 @@ dependencies = [ "wasip3", ] +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "gimli" version = "0.33.0" @@ -1362,6 +1461,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.4.0" @@ -1587,6 +1692,7 @@ dependencies = [ "gdbstub", "gdbstub_arch", "goblin", + "hex", "hyperlight-common", "hyperlight-component-macro", "hyperlight-guest-tracing", @@ -1602,6 +1708,7 @@ dependencies = [ "metrics-util", "mshv-bindings", "mshv-ioctls", + "oci-spec", "opentelemetry", "opentelemetry-otlp", "opentelemetry-semantic-conventions", @@ -1614,6 +1721,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2", "signal-hook-registry", "tempfile", "termcolor", @@ -1792,6 +1900,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1915,6 +2029,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "kurbo" version = "0.11.3" @@ -2335,6 +2464,23 @@ dependencies = [ "ruzstd", ] +[[package]] +name = "oci-spec" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3da52b83ce3258fbf29f66ac784b279453c2ac3c22c5805371b921ede0d308" +dependencies = [ + "const_format", + "derive_builder", + "getset", + "regex", + "serde", + "serde_json", + "strum", + "strum_macros", + "thiserror", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2781,6 +2927,28 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3455,6 +3623,24 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "syn" version = "2.0.117" diff --git a/src/hyperlight_host/Cargo.toml b/src/hyperlight_host/Cargo.toml index eb37fdddc..4a2dbdfe7 100644 --- a/src/hyperlight_host/Cargo.toml +++ b/src/hyperlight_host/Cargo.toml @@ -49,9 +49,13 @@ thiserror = "2.0.18" chrono = { version = "0.4", optional = true } anyhow = "1.0" metrics = "0.24.6" +serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" elfcore = { version = "2.0", optional = true } uuid = { version = "1.23.1", features = ["v4"] } +oci-spec = { version = "0.8", default-features = false, features = ["image"] } +sha2 = "0.10" +hex = "0.4" [target.'cfg(windows)'.dependencies] windows = { version = "0.62", features = [ @@ -83,7 +87,6 @@ mshv-ioctls = { version = "0.6", optional = true} [dev-dependencies] uuid = { version = "1.23.1", features = ["v4"] } signal-hook-registry = "1.4.8" -serde = "1.0" iced-x86 = { version = "1.21", default-features = false, features = ["std", "code_asm"] } proptest = "1.11.0" tempfile = "3.27.0" diff --git a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs index e98b0054b..16135f245 100644 --- a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs +++ b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs @@ -188,6 +188,20 @@ impl MultiUseSandbox { /// # Ok(()) /// # } /// ``` + /// + /// From a snapshot loaded from disk: + /// + /// ```no_run + /// # use std::sync::Arc; + /// # use hyperlight_host::{HostFunctions, MultiUseSandbox}; + /// # use hyperlight_host::sandbox::snapshot::Snapshot; + /// # fn example() -> Result<(), Box> { + /// let snapshot = Arc::new(Snapshot::from_oci("./guest_snapshot", "latest")?); + /// let mut sandbox = MultiUseSandbox::from_snapshot(snapshot, HostFunctions::default(), None)?; + /// let result: String = sandbox.call("Echo", "hello".to_string())?; + /// # Ok(()) + /// # } + /// ``` #[instrument(err(Debug), skip_all, parent = Span::current(), level = "Trace")] pub fn from_snapshot( snapshot: Arc, diff --git a/src/hyperlight_host/src/sandbox/snapshot/file.rs b/src/hyperlight_host/src/sandbox/snapshot/file.rs new file mode 100644 index 000000000..53a5171e5 --- /dev/null +++ b/src/hyperlight_host/src/sandbox/snapshot/file.rs @@ -0,0 +1,1392 @@ +/* +Copyright 2025 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. +*/ + +//! On-disk snapshot format: an OCI Image Layout directory. +//! +//! Layout produced by `Snapshot::to_oci`: +//! +//! ```text +//! path/ +//! oci-layout {"imageLayoutVersion":"1.0.0"} +//! index.json one descriptor per tagged snapshot, +//! each tagged via the OCI standard +//! `org.opencontainers.image.ref.name` +//! annotation +//! blobs/sha256/ +//! OCI image manifest JSON +//! Hyperlight config JSON +//! raw memory bytes +//! (`memory_size` bytes) +//! ``` +//! +//! ## Multiple snapshots per layout +//! +//! A single layout directory can hold any number of snapshots, each +//! addressed by tag. Adding a snapshot to an existing layout is the +//! point of OCI Image Layout: blobs are content-addressed by sha256, +//! so two snapshots that share bytes (a common base, an identical +//! config, and so on) share files in `blobs/sha256/`. This is how +//! the format dovetails with `oras cp` / registry pull-push pipelines +//! that move many tagged snapshots through one store. +//! +//! ## Strictness boundary +//! +//! The Hyperlight config blob (`HlConfig` and friends) uses +//! `#[serde(deny_unknown_fields)]` everywhere: any unknown key is a +//! breaking media-type bump, by design. +//! +//! The OCI manifest and index JSON are parsed via `oci-spec`'s +//! `ImageManifest` / `ImageIndex`, which do **not** use +//! `deny_unknown_fields`. This is intentional: third-party tools +//! (cosign, ORAS, build pipelines) routinely add manifest- and +//! index-level annotations, and a sandbox image must continue to +//! load even when those extras are present. +//! +//! ## Snapshot blob layout +//! +//! The snapshot blob is the raw memory image: exactly +//! `memory_size` bytes. Guard pages live in the host mapping +//! around the blob: +//! +//! * On Linux, an anonymous `PROT_NONE` reservation surrounds a +//! `MAP_FIXED` file mapping in the middle. +//! * On Windows, a `VirtualAlloc2` placeholder reservation surrounds +//! a `MapViewOfFile3` view in the middle, with guard pages held as +//! reserved (but unmapped) address space. +//! +//! The blob is byte-identical across platforms, so sharing it +//! through OCI registries works unchanged. + +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::Path; + +use hyperlight_common::flatbuffer_wrappers::function_types::{ParameterType, ReturnType}; +use hyperlight_common::flatbuffer_wrappers::host_function_definition::HostFunctionDefinition; +use hyperlight_common::flatbuffer_wrappers::host_function_details::HostFunctionDetails; +use hyperlight_common::vmem::PAGE_SIZE; +use oci_spec::image::{ + DescriptorBuilder, Digest, ImageIndex, ImageIndexBuilder, ImageManifest, ImageManifestBuilder, + MediaType, SCHEMA_VERSION, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +use super::{NextAction, SANDBOX_CONFIGURATION_COUNTER, Snapshot}; +use crate::hypervisor::regs::{CommonSegmentRegister, CommonSpecialRegisters, CommonTableRegister}; +use crate::mem::layout::SandboxMemoryLayout; +use crate::mem::memory_region::MemoryRegionFlags; +use crate::mem::shared_mem::{ReadonlySharedMemory, SharedMemory}; + +// --- Constants ------------------------------------------------------ + +const OCI_LAYOUT_VERSION: &str = "1.0.0"; + +// Media types are versioned by suffix. The loader matches each +// version specifically (no `_CURRENT` shortcut on the read side); the +// writer always emits `_CURRENT`. A new version is added by: +// +// 1. Declare `MT_FOO_V2` next to `MT_FOO_V1`. +// 2. Point `MT_FOO_CURRENT` at `MT_FOO_V2`. +// 3. Add a dispatch arm in the loader that converts v1 -> v2 (or +// rejects v1 if no compatibility window is offered). +const MT_CONFIG_V1: &str = "application/vnd.hyperlight.sandbox.config.v1+json"; +const MT_CONFIG_CURRENT: &str = MT_CONFIG_V1; +const MT_SNAPSHOT_V1: &str = "application/vnd.hyperlight.snapshot.v1"; +const MT_SNAPSHOT_CURRENT: &str = MT_SNAPSHOT_V1; + +/// ABI version for the snapshot memory blob. Bumped whenever the +/// host-guest contract for the bytes inside the snapshot blob changes +/// (PEB layout, calling convention, init state, etc.). Independent of +/// the config blob's media-type version. +const SNAPSHOT_ABI_VERSION: u32 = 1; + +/// Maximum size of the config JSON blob. Bounds the allocation done +/// before we parse the JSON. +const MAX_CONFIG_BLOB_SIZE: u64 = 1024 * 1024; + +impl Snapshot { + /// Save this snapshot as a tagged manifest inside an OCI Image + /// Layout directory at `path`. + /// + /// `tag` is written to `index.json` as + /// `org.opencontainers.image.ref.name` and must satisfy the OCI + /// tag grammar (`[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}`). + /// + /// If `path` does not exist, a new OCI layout is created. If it + /// exists, it must already be a valid OCI layout. Existing blobs + /// are deduplicated by digest. Existing tags are not overwritten. + /// + /// Save is not crash-atomic. If interrupted, delete the layout and + /// re-save. + /// + /// # Portability + /// + /// Snapshot images are not portable across CPU architectures, + /// hypervisors, or operating systems. All three are recorded in + /// the config blob and checked at load time; mismatches are + /// rejected with a clear error. + pub fn to_oci(&self, path: impl AsRef, tag: &str) -> crate::Result<()> { + let path = path.as_ref(); + validate_tag(tag)?; + + // Decide whether we are creating a fresh layout or appending + // to an existing one. + let appending = match std::fs::symlink_metadata(path) { + Ok(meta) if meta.is_dir() => { + // Directory exists. It must be a valid (empty or + // populated) OCI layout, otherwise we refuse to + // touch it. An empty directory is treated as + // not-a-layout to avoid quietly turning user data + // into a snapshot store. + if !read_oci_layout_marker(path)? { + return Err(crate::new_error!( + "to_oci refusing to write: {:?} exists but is not an OCI image layout \ + (no `oci-layout` marker). Remove it or choose a different path.", + path + )); + } + true + } + Ok(_) => { + return Err(crate::new_error!( + "to_oci refusing to write: path {:?} exists but is not a directory", + path + )); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(e) => { + return Err(crate::new_error!("to_oci failed to stat {:?}: {}", path, e)); + } + }; + + // Load the existing index (if any) so we can detect a tag + // collision before doing any work. + let mut existing_manifests: Vec = if appending { + let idx = ImageIndex::from_file(path.join("index.json")).map_err(|e| { + crate::new_error!( + "to_oci: failed to read existing index.json at {:?}: {}", + path.join("index.json"), + e + ) + })?; + if let Some(existing) = idx.manifests().iter().find(|d| { + d.annotations() + .as_ref() + .and_then(|a| a.get(ANNOTATION_REF_NAME)) + .map(|s| s.as_str() == tag) + .unwrap_or(false) + }) { + return Err(crate::new_error!( + "to_oci refusing to overwrite tag {:?} in {:?} (existing digest {}). \ + Delete the tag first or use a different name.", + tag, + path, + existing.digest().as_ref() + )); + } + idx.manifests().clone() + } else { + Vec::new() + }; + + let blobs_dir = path.join("blobs").join("sha256"); + std::fs::create_dir_all(&blobs_dir) + .map_err(|e| crate::new_error!("failed to create OCI layout dir {:?}: {}", path, e))?; + + // 1. Snapshot blob: the raw memory bytes. Stream sha256 + // hasher over the bytes as we write, then rename to the + // digest filename. If a blob with the same digest + // already exists in this layout (another snapshot shares + // it), discard the temp file. + let memory_bytes = self.memory.as_slice(); + let memory_size = memory_bytes.len(); + if memory_size == 0 || memory_size % PAGE_SIZE != 0 { + return Err(crate::new_error!( + "snapshot memory size {} must be a non-zero multiple of PAGE_SIZE", + memory_size + )); + } + let blob_total = memory_size; + + let snapshot_digest = { + let tmp_path = blobs_dir.join(".tmp-snapshot"); + let mut f = std::fs::File::create(&tmp_path).map_err(|e| { + crate::new_error!("failed to create snapshot blob temp {:?}: {}", tmp_path, e) + })?; + let mut hasher = Sha256::new(); + + f.write_all(memory_bytes) + .map_err(|e| crate::new_error!("snapshot blob write error: {}", e))?; + hasher.update(memory_bytes); + + let digest = Digest256::from_hasher(hasher); + let final_path = blobs_dir.join(&digest.hex); + if final_path.exists() { + // Same content already on disk, contributed by an + // earlier tag. Discard the temp file. + let _ = std::fs::remove_file(&tmp_path); + } else { + std::fs::rename(&tmp_path, &final_path).map_err(|e| { + crate::new_error!( + "failed to rename snapshot blob {:?} -> {:?}: {}", + tmp_path, + final_path, + e + ) + })?; + } + digest + }; + + // 2. Config blob. + let cfg = self.build_config()?; + let cfg_bytes = serde_json::to_vec_pretty(&cfg) + .map_err(|e| crate::new_error!("failed to serialise config JSON: {}", e))?; + let cfg_digest = Digest256::from_bytes(&cfg_bytes); + write_blob_dedup(&blobs_dir, &cfg_digest.hex, &cfg_bytes)?; + + // 3. Manifest. + let config_descriptor = DescriptorBuilder::default() + .media_type(MediaType::Other(MT_CONFIG_CURRENT.to_string())) + .digest(oci_digest(&cfg_digest)?) + .size(cfg_bytes.len() as u64) + .build() + .map_err(|e| crate::new_error!("failed to build config descriptor: {}", e))?; + let snapshot_descriptor = DescriptorBuilder::default() + .media_type(MediaType::Other(MT_SNAPSHOT_CURRENT.to_string())) + .digest(oci_digest(&snapshot_digest)?) + .size(blob_total as u64) + .build() + .map_err(|e| crate::new_error!("failed to build snapshot descriptor: {}", e))?; + let manifest = ImageManifestBuilder::default() + .schema_version(SCHEMA_VERSION) + .media_type(MediaType::ImageManifest) + .config(config_descriptor) + .layers(vec![snapshot_descriptor]) + .build() + .map_err(|e| crate::new_error!("failed to build OCI manifest: {}", e))?; + let manifest_bytes = serde_json::to_vec_pretty(&manifest) + .map_err(|e| crate::new_error!("failed to serialise OCI manifest: {}", e))?; + let manifest_digest = Digest256::from_bytes(&manifest_bytes); + write_blob_dedup(&blobs_dir, &manifest_digest.hex, &manifest_bytes)?; + + // 4. Append manifest descriptor (with the tag annotation) + // to index.json. + let mut anns = std::collections::HashMap::new(); + anns.insert(ANNOTATION_REF_NAME.to_string(), tag.to_string()); + let manifest_descriptor = DescriptorBuilder::default() + .media_type(MediaType::ImageManifest) + .digest(oci_digest(&manifest_digest)?) + .size(manifest_bytes.len() as u64) + .annotations(anns) + .build() + .map_err(|e| crate::new_error!("failed to build manifest descriptor: {}", e))?; + existing_manifests.push(manifest_descriptor); + let index = ImageIndexBuilder::default() + .schema_version(SCHEMA_VERSION) + .media_type(MediaType::ImageIndex) + .manifests(existing_manifests) + .build() + .map_err(|e| crate::new_error!("failed to build OCI index: {}", e))?; + let index_bytes = serde_json::to_vec_pretty(&index) + .map_err(|e| crate::new_error!("failed to serialise OCI index: {}", e))?; + std::fs::write(path.join("index.json"), &index_bytes) + .map_err(|e| crate::new_error!("failed to write index.json: {}", e))?; + + // 5. oci-layout marker (idempotent: same content every time). + if !appending { + let layout_bytes = serde_json::to_vec(&serde_json::json!({ + "imageLayoutVersion": OCI_LAYOUT_VERSION, + })) + .map_err(|e| crate::new_error!("failed to serialise oci-layout: {}", e))?; + std::fs::write(path.join("oci-layout"), &layout_bytes) + .map_err(|e| crate::new_error!("failed to write oci-layout: {}", e))?; + } + + Ok(()) + } + + fn build_config(&self) -> crate::Result { + let entrypoint = match (self.entrypoint, self.sregs.as_ref()) { + (NextAction::Initialise(addr), None) => EntrypointRepr::Initialise { addr }, + (NextAction::Call(addr), Some(sregs)) => EntrypointRepr::Call { + addr, + sregs: Box::new(SregsRepr::from(sregs)), + }, + (NextAction::Initialise(_), Some(_)) => { + return Err(crate::new_error!( + "snapshot inconsistent: Initialise entrypoint must not have sregs" + )); + } + (NextAction::Call(_), None) => { + return Err(crate::new_error!( + "snapshot inconsistent: Call entrypoint must have sregs" + )); + } + #[cfg(test)] + (NextAction::None, _) => { + return Err(crate::new_error!( + "snapshot with NextAction::None cannot be persisted" + )); + } + }; + + let host_functions = match &self.host_functions.host_functions { + Some(v) => v.iter().map(HostFunctionRepr::from).collect(), + None => Vec::new(), + }; + + let l = &self.layout; + Ok(HlConfig { + hyperlight_version: env!("CARGO_PKG_VERSION").to_string(), + arch: ArchTag::current(), + abi_version: SNAPSHOT_ABI_VERSION, + hypervisor: HypervisorTag::current() + .ok_or_else(|| crate::new_error!("no hypervisor available to tag snapshot"))?, + stack_top_gva: self.stack_top_gva, + entrypoint, + layout: LayoutFields { + input_data_size: l.input_data_size, + output_data_size: l.output_data_size, + heap_size: l.heap_size, + code_size: l.code_size, + init_data_size: l.init_data_size, + init_data_permissions: l.init_data_permissions.map(|f| f.bits()), + scratch_size: l.get_scratch_size(), + snapshot_size: l.snapshot_size, + pt_size: l.pt_size, + }, + memory_size: self.memory.mem_size() as u64, + host_functions, + }) + } + + /// Load the snapshot tagged `tag` from an OCI Image Layout + /// directory at `path`. + /// + /// `tag` selects one manifest from `index.json` using + /// `org.opencontainers.image.ref.name`. Missing tags and duplicate + /// tags are rejected. + /// + /// This verifies sha256 for manifest, config, and snapshot blobs. + /// Use [`Snapshot::from_oci_unchecked`] to skip digest verification + /// in trusted paths. + /// + /// Returns an error for arch, hypervisor, OS, and ABI mismatches. + /// + /// # File-mutation hazard + /// + /// Do not modify or replace files in `path` while the returned + /// `Snapshot` (or sandboxes built from it) is still alive. + pub fn from_oci(path: impl AsRef, tag: &str) -> crate::Result { + Self::from_oci_inner(path.as_ref(), tag, true) + } + + /// Like [`Snapshot::from_oci`] but **skips sha256 verification of + /// the manifest, config, and snapshot blob bytes**, trading + /// integrity checking for performance. All other validation + /// (OCI structure, descriptor sizes, schema versions, arch / + /// hypervisor / ABI tags, layout bounds, entrypoint bounds) is + /// unchanged. + pub fn from_oci_unchecked(path: impl AsRef, tag: &str) -> crate::Result { + Self::from_oci_inner(path.as_ref(), tag, false) + } + + fn from_oci_inner(path: &Path, tag: &str, verify_blobs: bool) -> crate::Result { + validate_tag(tag)?; + let meta = std::fs::metadata(path) + .map_err(|e| crate::new_error!("from_oci failed to stat {:?}: {}", path, e))?; + if !meta.is_dir() { + return Err(crate::new_error!( + "from_oci path {:?} is not a directory", + path + )); + } + + // 1. oci-layout + let layout_bytes = std::fs::read(path.join("oci-layout")).map_err(|e| { + crate::new_error!( + "missing or unreadable oci-layout at {:?}: {}", + path.join("oci-layout"), + e + ) + })?; + let layout_json: serde_json::Value = serde_json::from_slice(&layout_bytes) + .map_err(|e| crate::new_error!("oci-layout is not valid JSON: {}", e))?; + let v = layout_json + .get("imageLayoutVersion") + .and_then(|v| v.as_str()) + .ok_or_else(|| crate::new_error!("oci-layout missing imageLayoutVersion field"))?; + if v != OCI_LAYOUT_VERSION { + return Err(crate::new_error!( + "unsupported OCI image layout version {:?} (expected {:?})", + v, + OCI_LAYOUT_VERSION + )); + } + + // 2. index.json -> manifest descriptor for `tag`. Multiple + // manifests are fine in OCI Image Layout; we select the + // one whose `org.opencontainers.image.ref.name` annotation + // matches the requested tag. Two manifests with the same + // tag is a malformed layout. + let index = ImageIndex::from_file(path.join("index.json")) + .map_err(|e| crate::new_error!("failed to read or parse index.json: {}", e))?; + let mut matching = index.manifests().iter().filter(|d| { + d.annotations() + .as_ref() + .and_then(|a| a.get(ANNOTATION_REF_NAME)) + .map(|s| s.as_str() == tag) + .unwrap_or(false) + }); + let manifest_desc = match (matching.next(), matching.next()) { + (None, _) => { + let known: Vec<&str> = index + .manifests() + .iter() + .filter_map(|d| { + d.annotations() + .as_ref() + .and_then(|a| a.get(ANNOTATION_REF_NAME)) + .map(|s| s.as_str()) + }) + .collect(); + return Err(crate::new_error!( + "no manifest tagged {:?} in OCI layout {:?}. Available tags: {:?}", + tag, + path, + known + )); + } + (Some(_), Some(_)) => { + return Err(crate::new_error!( + "OCI layout {:?} has multiple manifests tagged {:?}; tags must be unique", + path, + tag + )); + } + (Some(d), None) => d, + }; + let manifest_hex = parse_oci_digest(manifest_desc.digest().as_ref())?; + + // 3. manifest blob + let manifest_path = path.join("blobs").join("sha256").join(&manifest_hex); + let manifest_bytes = read_bounded(&manifest_path, MAX_CONFIG_BLOB_SIZE)?; + if manifest_bytes.len() as u64 != manifest_desc.size() { + return Err(crate::new_error!( + "OCI manifest size mismatch: descriptor says {}, file is {}", + manifest_desc.size(), + manifest_bytes.len() + )); + } + if verify_blobs { + verify_blob_bytes("manifest", &manifest_bytes, &manifest_hex)?; + } + let manifest: ImageManifest = serde_json::from_slice(&manifest_bytes) + .map_err(|e| crate::new_error!("failed to parse OCI manifest JSON: {}", e))?; + if manifest.schema_version() != SCHEMA_VERSION { + return Err(crate::new_error!( + "unsupported OCI manifest schemaVersion {} (expected {})", + manifest.schema_version(), + SCHEMA_VERSION + )); + } + let cfg_desc = manifest.config(); + // Loader dispatch on config media type. Today only v1 exists; + // v2 lands as a new arm here that converts to the in-memory + // current shape. + let cfg_media = cfg_desc.media_type().to_string(); + match cfg_media.as_str() { + MT_CONFIG_V1 => {} + other => { + return Err(crate::new_error!( + "unexpected config media type {:?} (supported: {:?})", + other, + MT_CONFIG_V1 + )); + } + } + let layers = manifest.layers(); + if layers.len() != 1 { + return Err(crate::new_error!( + "expected exactly one OCI layer (the snapshot), found {}", + layers.len() + )); + } + let snap_desc = &layers[0]; + let snap_media = snap_desc.media_type().to_string(); + match snap_media.as_str() { + MT_SNAPSHOT_V1 => {} + other => { + return Err(crate::new_error!( + "unexpected snapshot layer media type {:?} (supported: {:?})", + other, + MT_SNAPSHOT_V1 + )); + } + } + + // 4. config blob + let cfg_hex = parse_oci_digest(cfg_desc.digest().as_ref())?; + let cfg_path = path.join("blobs").join("sha256").join(&cfg_hex); + let cfg_bytes = read_bounded(&cfg_path, MAX_CONFIG_BLOB_SIZE)?; + if cfg_bytes.len() as u64 != cfg_desc.size() { + return Err(crate::new_error!( + "config blob size mismatch: descriptor says {}, file is {}", + cfg_desc.size(), + cfg_bytes.len() + )); + } + if verify_blobs { + verify_blob_bytes("config", &cfg_bytes, &cfg_hex)?; + } + let cfg: HlConfig = serde_json::from_slice(&cfg_bytes) + .map_err(|e| crate::new_error!("failed to parse Hyperlight config JSON: {}", e))?; + cfg.validate_for_load()?; + + // 5. snapshot blob: open once, hash and mmap the same + // handle so an attacker cannot swap the file between + // verification and mapping. + let snap_hex = parse_oci_digest(snap_desc.digest().as_ref())?; + let snap_path = path.join("blobs").join("sha256").join(&snap_hex); + let mut snap_file = std::fs::File::open(&snap_path).map_err(|e| { + crate::new_error!("failed to open snapshot blob {:?}: {}", snap_path, e) + })?; + let snap_file_len = snap_file + .metadata() + .map_err(|e| crate::new_error!("failed to stat snapshot blob: {}", e))? + .len(); + let expected_blob_len = cfg.memory_size; + if snap_file_len != expected_blob_len { + return Err(crate::new_error!( + "snapshot blob size mismatch: file is {} bytes, expected {} \ + (memory_size)", + snap_file_len, + expected_blob_len, + )); + } + if snap_file_len != snap_desc.size() { + return Err(crate::new_error!( + "snapshot blob size {} disagrees with OCI descriptor size {}", + snap_file_len, + snap_desc.size() + )); + } + if verify_blobs { + verify_blob_file("snapshot", &mut snap_file, &snap_hex)?; + } + + // 6. Reconstruct layout. + let mut sbox_cfg = crate::sandbox::SandboxConfiguration::default(); + sbox_cfg.set_input_data_size(cfg.layout.input_data_size); + sbox_cfg.set_output_data_size(cfg.layout.output_data_size); + sbox_cfg.set_heap_size(cfg.layout.heap_size as u64); + sbox_cfg.set_scratch_size(cfg.layout.scratch_size); + let init_data_perms = match cfg.layout.init_data_permissions { + None => None, + Some(bits) => Some(MemoryRegionFlags::from_bits(bits).ok_or_else(|| { + crate::new_error!( + "snapshot init_data_permissions {:#x} contains unknown flag bits", + bits + ) + })?), + }; + let mut layout = SandboxMemoryLayout::new( + sbox_cfg, + cfg.layout.code_size, + cfg.layout.init_data_size, + init_data_perms, + )?; + // `snapshot_size` and `pt_size` are independent fields. + if let Some(pt) = cfg.layout.pt_size { + layout.set_pt_size(pt)?; + } + layout.set_snapshot_size(cfg.layout.snapshot_size); + + // 7. mmap the snapshot blob (file-backed CoW). The blob is + // the raw memory image. `ReadonlySharedMemory::from_file` + // surrounds it with host guard pages. The guest mapping + // of the snapshot region covers only the data prefix + // (`snapshot_size`). The PT tail sits past that prefix + // in the host mapping and is copied into the scratch + // region on restore. Keeping it out of the guest mapping + // of the snapshot region avoids overlap with + // `map_file_cow` regions installed immediately after the + // snapshot in guest PA space. + let memory = ReadonlySharedMemory::from_file(&snap_file, Some(layout.snapshot_size))?; + + // 8. Build entrypoint + sregs back from the tagged enum. + let (entrypoint, sregs) = match cfg.entrypoint { + EntrypointRepr::Initialise { addr } => (NextAction::Initialise(addr), None), + EntrypointRepr::Call { addr, sregs } => ( + NextAction::Call(addr), + Some(CommonSpecialRegisters::from(*sregs)), + ), + }; + + // 9. Reconstitute host_functions metadata. + let host_funcs_vec: Vec = + cfg.host_functions.into_iter().map(Into::into).collect(); + let host_functions = if host_funcs_vec.is_empty() { + HostFunctionDetails { + host_functions: None, + } + } else { + HostFunctionDetails { + host_functions: Some(host_funcs_vec), + } + }; + + Ok(Snapshot { + sandbox_id: SANDBOX_CONFIGURATION_COUNTER + .fetch_add(1, std::sync::atomic::Ordering::Relaxed), + layout, + memory, + regions: Vec::new(), + load_info: crate::mem::exe::LoadInfo::dummy(), + stack_top_gva: cfg.stack_top_gva, + sregs, + entrypoint, + snapshot_generation: 0, + host_functions, + }) + } +} + +// --- Hypervisor / arch tags ----------------------------------------- + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +enum ArchTag { + X86_64, + Aarch64, + I686, +} + +impl ArchTag { + fn current() -> Self { + #[cfg(feature = "i686-guest")] + { + Self::I686 + } + #[cfg(all(not(feature = "i686-guest"), target_arch = "x86_64"))] + { + Self::X86_64 + } + #[cfg(all(not(feature = "i686-guest"), target_arch = "aarch64"))] + { + Self::Aarch64 + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(super) enum HypervisorTag { + Kvm, + Mshv, + Whp, +} + +impl HypervisorTag { + pub(super) fn current() -> Option { + #[allow(unused_imports)] + use crate::hypervisor::virtual_machine::HypervisorType; + use crate::hypervisor::virtual_machine::get_available_hypervisor; + + match get_available_hypervisor() { + #[cfg(kvm)] + Some(HypervisorType::Kvm) => Some(Self::Kvm), + #[cfg(mshv3)] + Some(HypervisorType::Mshv) => Some(Self::Mshv), + #[cfg(target_os = "windows")] + Some(HypervisorType::Whp) => Some(Self::Whp), + None => None, + } + } + + fn name(&self) -> &'static str { + match self { + Self::Kvm => "KVM", + Self::Mshv => "MSHV", + Self::Whp => "WHP", + } + } +} + +// --- Config JSON shape ---------------------------------------------- + +/// Top-level Hyperlight sandbox config JSON. Lives at +/// `blobs/sha256/` with media type +/// `application/vnd.hyperlight.sandbox.config.v1+json`. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct HlConfig { + /// Hyperlight crate version that produced this config. Recorded + /// for diagnostics. Not checked on load. + hyperlight_version: String, + arch: ArchTag, + /// Memory blob ABI version. See [`SNAPSHOT_ABI_VERSION`]. + abi_version: u32, + hypervisor: HypervisorTag, + /// Top of the guest stack, in guest virtual address space. + stack_top_gva: u64, + /// Tagged enum: `Initialise` carries an entry-point address only; + /// `Call` carries the dispatch function pointer plus the captured + /// sregs from the running vCPU. The shape itself enforces the + /// "Call has sregs, Initialise does not" invariant. + entrypoint: EntrypointRepr, + layout: LayoutFields, + /// Total size of the memory blob in bytes (including the guest + /// page-table tail, if any). Equal to `self.memory.mem_size()`. + memory_size: u64, + /// Names and signatures of host functions registered when this + /// snapshot was taken. Validated against the loader's registry. + host_functions: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)] +/// On-disk next action stored with a snapshot. +/// +/// The enum shape enforces the invariant that `Call` includes `sregs` +/// and `Initialise` does not. Serde rejects missing or extra `sregs` +/// at parse time. +enum EntrypointRepr { + Initialise { addr: u64 }, + Call { addr: u64, sregs: Box }, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct LayoutFields { + input_data_size: usize, + output_data_size: usize, + heap_size: usize, + code_size: usize, + init_data_size: usize, + /// Memory region flag bits. `None` means default permissions. + init_data_permissions: Option, + scratch_size: usize, + snapshot_size: usize, + pt_size: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct HostFunctionRepr { + function_name: String, + parameter_types: Vec, + return_type: ReturnTypeRepr, +} + +/// JSON-friendly mirror of +/// [`hyperlight_common::flatbuffer_wrappers::function_types::ParameterType`]. +/// Kept local so we don't have to plumb serde through `hyperlight_common`. +/// The `match`es below are exhaustive: any new variant upstream forces +/// an explicit decision here. +#[derive(Serialize, Deserialize, Copy, Clone)] +#[serde(rename_all = "snake_case")] +enum ParameterTypeRepr { + Int, + UInt, + Long, + ULong, + Float, + Double, + String, + Bool, + VecBytes, +} + +/// JSON-friendly mirror of +/// [`hyperlight_common::flatbuffer_wrappers::function_types::ReturnType`]. +#[derive(Serialize, Deserialize, Copy, Clone)] +#[serde(rename_all = "snake_case")] +enum ReturnTypeRepr { + Int, + UInt, + Long, + ULong, + Float, + Double, + String, + Bool, + Void, + VecBytes, +} + +impl From<&ParameterType> for ParameterTypeRepr { + fn from(p: &ParameterType) -> Self { + match p { + ParameterType::Int => Self::Int, + ParameterType::UInt => Self::UInt, + ParameterType::Long => Self::Long, + ParameterType::ULong => Self::ULong, + ParameterType::Float => Self::Float, + ParameterType::Double => Self::Double, + ParameterType::String => Self::String, + ParameterType::Bool => Self::Bool, + ParameterType::VecBytes => Self::VecBytes, + } + } +} + +impl From for ParameterType { + fn from(r: ParameterTypeRepr) -> Self { + match r { + ParameterTypeRepr::Int => Self::Int, + ParameterTypeRepr::UInt => Self::UInt, + ParameterTypeRepr::Long => Self::Long, + ParameterTypeRepr::ULong => Self::ULong, + ParameterTypeRepr::Float => Self::Float, + ParameterTypeRepr::Double => Self::Double, + ParameterTypeRepr::String => Self::String, + ParameterTypeRepr::Bool => Self::Bool, + ParameterTypeRepr::VecBytes => Self::VecBytes, + } + } +} + +impl From<&ReturnType> for ReturnTypeRepr { + fn from(r: &ReturnType) -> Self { + match r { + ReturnType::Int => Self::Int, + ReturnType::UInt => Self::UInt, + ReturnType::Long => Self::Long, + ReturnType::ULong => Self::ULong, + ReturnType::Float => Self::Float, + ReturnType::Double => Self::Double, + ReturnType::String => Self::String, + ReturnType::Bool => Self::Bool, + ReturnType::Void => Self::Void, + ReturnType::VecBytes => Self::VecBytes, + } + } +} + +impl From for ReturnType { + fn from(r: ReturnTypeRepr) -> Self { + match r { + ReturnTypeRepr::Int => Self::Int, + ReturnTypeRepr::UInt => Self::UInt, + ReturnTypeRepr::Long => Self::Long, + ReturnTypeRepr::ULong => Self::ULong, + ReturnTypeRepr::Float => Self::Float, + ReturnTypeRepr::Double => Self::Double, + ReturnTypeRepr::String => Self::String, + ReturnTypeRepr::Bool => Self::Bool, + ReturnTypeRepr::Void => Self::Void, + ReturnTypeRepr::VecBytes => Self::VecBytes, + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SregsRepr { + cs: SegmentRegisterRepr, + ds: SegmentRegisterRepr, + es: SegmentRegisterRepr, + fs: SegmentRegisterRepr, + gs: SegmentRegisterRepr, + ss: SegmentRegisterRepr, + tr: SegmentRegisterRepr, + ldt: SegmentRegisterRepr, + gdt: TableRegisterRepr, + idt: TableRegisterRepr, + cr0: u64, + cr2: u64, + cr3: u64, + cr4: u64, + cr8: u64, + efer: u64, + apic_base: u64, + interrupt_bitmap: [u64; 4], +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SegmentRegisterRepr { + base: u64, + limit: u32, + selector: u16, + type_: u8, + present: u8, + dpl: u8, + db: u8, + s: u8, + l: u8, + g: u8, + avl: u8, + unusable: u8, + padding: u8, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct TableRegisterRepr { + base: u64, + limit: u16, +} + +// --- Conversions between repr and runtime types --------------------- + +impl From<&CommonSpecialRegisters> for SregsRepr { + fn from(s: &CommonSpecialRegisters) -> Self { + let seg = |r: &CommonSegmentRegister| SegmentRegisterRepr { + base: r.base, + limit: r.limit, + selector: r.selector, + type_: r.type_, + present: r.present, + dpl: r.dpl, + db: r.db, + s: r.s, + l: r.l, + g: r.g, + avl: r.avl, + unusable: r.unusable, + padding: r.padding, + }; + let tab = |r: &CommonTableRegister| TableRegisterRepr { + base: r.base, + limit: r.limit, + }; + Self { + cs: seg(&s.cs), + ds: seg(&s.ds), + es: seg(&s.es), + fs: seg(&s.fs), + gs: seg(&s.gs), + ss: seg(&s.ss), + tr: seg(&s.tr), + ldt: seg(&s.ldt), + gdt: tab(&s.gdt), + idt: tab(&s.idt), + cr0: s.cr0, + cr2: s.cr2, + cr3: s.cr3, + cr4: s.cr4, + cr8: s.cr8, + efer: s.efer, + apic_base: s.apic_base, + interrupt_bitmap: s.interrupt_bitmap, + } + } +} + +impl From for CommonSpecialRegisters { + fn from(r: SregsRepr) -> Self { + let seg = |s: SegmentRegisterRepr| CommonSegmentRegister { + base: s.base, + limit: s.limit, + selector: s.selector, + type_: s.type_, + present: s.present, + dpl: s.dpl, + db: s.db, + s: s.s, + l: s.l, + g: s.g, + avl: s.avl, + unusable: s.unusable, + padding: s.padding, + }; + let tab = |t: TableRegisterRepr| CommonTableRegister { + base: t.base, + limit: t.limit, + }; + Self { + cs: seg(r.cs), + ds: seg(r.ds), + es: seg(r.es), + fs: seg(r.fs), + gs: seg(r.gs), + ss: seg(r.ss), + tr: seg(r.tr), + ldt: seg(r.ldt), + gdt: tab(r.gdt), + idt: tab(r.idt), + cr0: r.cr0, + cr2: r.cr2, + cr3: r.cr3, + cr4: r.cr4, + cr8: r.cr8, + efer: r.efer, + apic_base: r.apic_base, + interrupt_bitmap: r.interrupt_bitmap, + } + } +} + +impl From<&HostFunctionDefinition> for HostFunctionRepr { + fn from(d: &HostFunctionDefinition) -> Self { + let parameter_types = d + .parameter_types + .as_ref() + .map(|v| v.iter().map(ParameterTypeRepr::from).collect()) + .unwrap_or_default(); + Self { + function_name: d.function_name.clone(), + parameter_types, + return_type: ReturnTypeRepr::from(&d.return_type), + } + } +} + +impl From for HostFunctionDefinition { + fn from(r: HostFunctionRepr) -> Self { + Self { + function_name: r.function_name, + parameter_types: Some(r.parameter_types.into_iter().map(Into::into).collect()), + return_type: r.return_type.into(), + } + } +} + +// --- sha256 helper -------------------------------------------------- + +/// A `sha256:` digest as recorded in OCI manifests. The bare hex +/// (without prefix) is also the blob's filename inside `blobs/sha256/`. +#[derive(Clone)] +struct Digest256 { + /// Lowercase hex of the 32-byte sha256 output. + hex: String, +} + +impl Digest256 { + fn from_bytes(bytes: &[u8]) -> Self { + let arr: [u8; 32] = Sha256::digest(bytes).into(); + Self { + hex: hex::encode(arr), + } + } + + fn from_hasher(h: Sha256) -> Self { + let arr: [u8; 32] = h.finalize().into(); + Self { + hex: hex::encode(arr), + } + } +} + +/// Build an `oci_spec::image::Digest` from a [`Digest256`]. +fn oci_digest(d: &Digest256) -> crate::Result { + Digest::try_from(format!("sha256:{}", d.hex)) + .map_err(|e| crate::new_error!("failed to construct OCI digest: {}", e)) +} + +fn parse_oci_digest(s: &str) -> crate::Result { + let rest = s.strip_prefix("sha256:").ok_or_else(|| { + crate::new_error!( + "OCI descriptor digest {:?} is not a sha256 digest (only sha256 is supported)", + s + ) + })?; + if rest.len() != 64 || !rest.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(crate::new_error!( + "OCI descriptor digest {:?} is not a 64-character lowercase hex string", + s + )); + } + Ok(rest.to_lowercase()) +} + +// --- HlConfig validation -------------------------------------------- + +impl HlConfig { + fn validate_for_load(&self) -> crate::Result<()> { + if self.arch != ArchTag::current() { + return Err(crate::new_error!( + "snapshot architecture mismatch: file is {:?}, current host is {:?}", + self.arch, + ArchTag::current() + )); + } + if self.abi_version != SNAPSHOT_ABI_VERSION { + return Err(crate::new_error!( + "snapshot ABI version mismatch: file has version {}, this build expects {}. \ + The snapshot must be regenerated from the guest binary.", + self.abi_version, + SNAPSHOT_ABI_VERSION + )); + } + let current_hv = HypervisorTag::current() + .ok_or_else(|| crate::new_error!("no hypervisor available to load snapshot"))?; + if self.hypervisor != current_hv { + return Err(crate::new_error!( + "snapshot hypervisor mismatch: file was created on {} but the current hypervisor is {}", + self.hypervisor.name(), + current_hv.name() + )); + } + // Bound memory size early so the subsequent file-size check + // does not have to deal with absurd values. + if self.memory_size == 0 || self.memory_size > SandboxMemoryLayout::MAX_MEMORY_SIZE as u64 { + return Err(crate::new_error!( + "snapshot memory_size ({}) is out of range", + self.memory_size + )); + } + if self.memory_size as usize % PAGE_SIZE != 0 { + return Err(crate::new_error!( + "snapshot memory_size ({}) is not a multiple of PAGE_SIZE", + self.memory_size + )); + } + // Invariant: `snapshot_size + pt_size == memory_size`. + // `snapshot_size` is the guest-visible prefix of the blob, + // mapped into guest PA space at `BASE_ADDRESS`. `pt_size` + // is the page-table tail that sits after it in the blob and + // the host mapping, outside the guest mapping of the + // snapshot region. + if self.layout.snapshot_size == 0 { + return Err(crate::new_error!("snapshot snapshot_size must be nonzero")); + } + if self.layout.snapshot_size % PAGE_SIZE != 0 { + return Err(crate::new_error!( + "snapshot snapshot_size ({}) is not a multiple of PAGE_SIZE", + self.layout.snapshot_size + )); + } + let pt = self.layout.pt_size.unwrap_or(0); + if pt % PAGE_SIZE != 0 { + return Err(crate::new_error!( + "snapshot pt_size ({}) is not a multiple of PAGE_SIZE", + pt + )); + } + if (self.layout.snapshot_size as u64).saturating_add(pt as u64) != self.memory_size { + return Err(crate::new_error!( + "snapshot snapshot_size ({}) + pt_size ({}) does not equal memory_size ({})", + self.layout.snapshot_size, + pt, + self.memory_size + )); + } + if let Some(bits) = self.layout.init_data_permissions { + MemoryRegionFlags::from_bits(bits).ok_or_else(|| { + crate::new_error!( + "snapshot init_data_permissions {:#x} contains unknown flag bits", + bits + ) + })?; + } + + // Entrypoint address must point inside the guest snapshot + // region. Hyperlight identity-maps the snapshot region in low + // GPAs, so the same bounds apply to virtual and physical + // addresses there. A crafted config could otherwise direct + // execution into unmapped GPA space (which only catches the + // bug at vCPU run time) or, worse, into the scratch region + // (which is writable). The bound here is + // `[BASE_ADDRESS, BASE_ADDRESS + snapshot_size)` because the + // snapshot blob covers exactly the snapshot region. + let snap_lo = SandboxMemoryLayout::BASE_ADDRESS as u64; + let snap_hi = snap_lo + .checked_add(self.layout.snapshot_size as u64) + .ok_or_else(|| { + crate::new_error!( + "snapshot layout overflow: BASE_ADDRESS + snapshot_size ({}) does not fit in u64", + self.layout.snapshot_size + ) + })?; + let entry_addr = match &self.entrypoint { + EntrypointRepr::Initialise { addr } => *addr, + EntrypointRepr::Call { addr, .. } => *addr, + }; + if entry_addr < snap_lo || entry_addr >= snap_hi { + return Err(crate::new_error!( + "snapshot entrypoint addr {:#x} is outside the snapshot region [{:#x}, {:#x})", + entry_addr, + snap_lo, + snap_hi + )); + } + Ok(()) + } +} + +// --- Save ----------------------------------------------------------- + +/// OCI standard annotation key for a manifest's tag inside an image +/// index. Set on the manifest descriptor in `index.json`, not on the +/// manifest blob itself. See the OCI Image Spec, "Annotations" and +/// the Image Layout spec. +const ANNOTATION_REF_NAME: &str = "org.opencontainers.image.ref.name"; + +/// Validate a tag against the OCI Distribution spec rules: +/// `[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}`. Required so that the same +/// strings work both in our local layout and when pushed to a +/// registry via `oras` / `crane` / `skopeo`. +fn validate_tag(tag: &str) -> crate::Result<()> { + let bytes = tag.as_bytes(); + if bytes.is_empty() || bytes.len() > 128 { + return Err(crate::new_error!( + "tag {:?} is invalid: must be 1..=128 bytes", + tag + )); + } + let first = bytes[0]; + if !(first.is_ascii_alphanumeric() || first == b'_') { + return Err(crate::new_error!( + "tag {:?} is invalid: first character must be alphanumeric or '_'", + tag + )); + } + for &b in &bytes[1..] { + if !(b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'-') { + return Err(crate::new_error!( + "tag {:?} is invalid: characters after the first must be \ + alphanumeric or one of '_', '.', '-'", + tag + )); + } + } + Ok(()) +} + +/// Read and parse `path/oci-layout`, asserting the version we +/// support. Returns `Ok(true)` if a valid layout marker was found, +/// `Ok(false)` if the file is absent (so the caller can decide +/// whether to create a fresh layout), or `Err` if the file is +/// present but malformed (treat as user data; refuse to touch). +fn read_oci_layout_marker(path: &Path) -> crate::Result { + let marker_path = path.join("oci-layout"); + let bytes = match std::fs::read(&marker_path) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(e) => { + return Err(crate::new_error!( + "failed to read oci-layout marker at {:?}: {}", + marker_path, + e + )); + } + }; + let v: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| { + crate::new_error!("oci-layout at {:?} is not valid JSON: {}", marker_path, e) + })?; + let version = v + .get("imageLayoutVersion") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + crate::new_error!("oci-layout at {:?} missing imageLayoutVersion", marker_path) + })?; + if version != OCI_LAYOUT_VERSION { + return Err(crate::new_error!( + "unsupported OCI image layout version {:?} at {:?} (expected {:?})", + version, + marker_path, + OCI_LAYOUT_VERSION + )); + } + Ok(true) +} + +/// Write a blob at `blobs/sha256/`, but skip the actual +/// write if a file with that name already exists. OCI blobs are +/// content-addressed, so an existing file with the same digest must +/// have the same bytes. Skipping reuses the blob across snapshots +/// in the same layout. +fn write_blob_dedup(blobs_dir: &Path, hex_digest: &str, bytes: &[u8]) -> crate::Result<()> { + let final_path = blobs_dir.join(hex_digest); + if final_path.exists() { + return Ok(()); + } + std::fs::write(&final_path, bytes) + .map_err(|e| crate::new_error!("failed to write blob {:?}: {}", final_path, e)) +} + +/// Compute sha256 of `bytes` and verify it equals `expected_hex`. +/// Used to validate manifest and config blobs (small, already in +/// memory). +fn verify_blob_bytes(label: &str, bytes: &[u8], expected_hex: &str) -> crate::Result<()> { + let actual = Digest256::from_bytes(bytes); + if actual.hex != expected_hex { + return Err(crate::new_error!( + "{} blob digest mismatch: descriptor declares sha256:{}, file hashes to sha256:{}", + label, + expected_hex, + actual.hex + )); + } + Ok(()) +} + +/// Stream-hash an already-open file and verify its sha256 equals +/// `expected_hex`. +/// +/// Takes the same `File` handle the caller will subsequently `mmap`, +/// not a path. Hashing one open and mapping another is open-then- +/// replace TOCTOU bait. Seeks to start before and after so the +/// caller's file position is unchanged. +fn verify_blob_file( + label: &str, + file: &mut std::fs::File, + expected_hex: &str, +) -> crate::Result<()> { + file.seek(SeekFrom::Start(0)) + .map_err(|e| crate::new_error!("failed to seek {} blob: {}", label, e))?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = file + .read(&mut buf) + .map_err(|e| crate::new_error!("failed to read {} blob: {}", label, e))?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + file.seek(SeekFrom::Start(0)) + .map_err(|e| crate::new_error!("failed to rewind {} blob: {}", label, e))?; + let actual = Digest256::from_hasher(hasher); + if actual.hex != expected_hex { + return Err(crate::new_error!( + "{} blob digest mismatch: descriptor declares sha256:{}, file hashes to sha256:{}", + label, + expected_hex, + actual.hex + )); + } + Ok(()) +} + +/// Read a file in full, refusing if the file is bigger than `max_size`. +/// +/// The cap is enforced on the actual byte stream via [`Read::take`], so files +/// whose `metadata().len()` is misleading cannot exceed the limit. +fn read_bounded(path: &Path, max_size: u64) -> crate::Result> { + let f = std::fs::File::open(path) + .map_err(|e| crate::new_error!("failed to open {:?}: {}", path, e))?; + let hint = f.metadata().map(|m| m.len().min(max_size)).unwrap_or(0); + let mut buf = Vec::with_capacity(hint as usize); + // Read one extra byte so we can distinguish "exactly at the limit" from + // "over the limit" instead of silently truncating an oversize file. + f.take(max_size.saturating_add(1)) + .read_to_end(&mut buf) + .map_err(|e| crate::new_error!("failed to read {:?}: {}", path, e))?; + if buf.len() as u64 > max_size { + return Err(crate::new_error!( + "file {:?} exceeds maximum allowed {} bytes", + path, + max_size + )); + } + Ok(buf) +} diff --git a/src/hyperlight_host/src/sandbox/snapshot/mod.rs b/src/hyperlight_host/src/sandbox/snapshot/mod.rs index f0551e25c..878b9040c 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/mod.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/mod.rs @@ -14,6 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ +mod file; +mod file_tests; + use std::collections::{BTreeMap, HashMap}; use std::sync::atomic::{AtomicU64, Ordering}; From 3403af7bf4fd96d81b2c7c8ebabfe41e0df42433 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Fri, 1 May 2026 16:01:13 -0700 Subject: [PATCH 11/12] Add tests for OCI snapshot persistence Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- .../src/sandbox/snapshot/file_tests.rs | 2574 +++++++++++++++++ 1 file changed, 2574 insertions(+) create mode 100644 src/hyperlight_host/src/sandbox/snapshot/file_tests.rs diff --git a/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs new file mode 100644 index 000000000..739db196c --- /dev/null +++ b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs @@ -0,0 +1,2574 @@ +/* +Copyright 2025 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. +*/ + +//! Tests for the OCI Image Layout snapshot format (`super::file`). + +#![cfg(test)] + +use std::sync::Arc; + +use hyperlight_testing::simple_guest_as_string; +use serde_json::Value; +use sha2::{Digest as _, Sha256}; + +use crate::func::Registerable; +use crate::sandbox::snapshot::Snapshot; +use crate::{GuestBinary, HostFunctions, MultiUseSandbox, UninitializedSandbox}; + +fn create_test_sandbox() -> MultiUseSandbox { + let path = simple_guest_as_string().unwrap(); + UninitializedSandbox::new(GuestBinary::FilePath(path), None) + .unwrap() + .evolve() + .unwrap() +} + +fn create_snapshot_from_binary() -> Snapshot { + let path = simple_guest_as_string().unwrap(); + Snapshot::from_env( + GuestBinary::FilePath(path), + crate::sandbox::SandboxConfiguration::default(), + ) + .unwrap() +} + +/// `Result::unwrap_err` requires `T: Debug`, but `Snapshot` is not +/// `Debug`. This wrapper is the test-side equivalent. +#[track_caller] +fn unwrap_err_snapshot(r: crate::Result) -> crate::HyperlightError { + match r { + Err(e) => e, + Ok(_) => panic!("expected Snapshot::from_oci to fail"), + } +} + +/// Locate the single config blob inside `oci_dir`. Returns its full +/// path. Used by tests that mutate the on-disk JSON. +fn find_config_blob(oci_dir: &std::path::Path) -> std::path::PathBuf { + let manifest_bytes = std::fs::read(oci_dir.join("index.json")).unwrap(); + let index: Value = serde_json::from_slice(&manifest_bytes).unwrap(); + let manifest_digest = index["manifests"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + let manifest_path = oci_dir.join("blobs").join("sha256").join(manifest_digest); + let manifest: Value = serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); + let cfg_digest = manifest["config"]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + oci_dir.join("blobs").join("sha256").join(cfg_digest) +} + +// ============================================================================= +// In-memory `from_snapshot` round-trips (no file I/O). +// ============================================================================= + +#[test] +fn from_snapshot_already_initialized_in_memory() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(snapshot, HostFunctions::default(), None).unwrap(); + let result: i32 = sbox2.call("GetStatic", ()).unwrap(); + assert_eq!(result, 0); +} + +#[test] +fn from_snapshot_in_memory_pre_init() { + let snap = create_snapshot_from_binary(); + let mut sbox = + MultiUseSandbox::from_snapshot(Arc::new(snap), HostFunctions::default(), None).unwrap(); + let result: i32 = sbox.call("GetStatic", ()).unwrap(); + assert_eq!(result, 0); +} + +// ============================================================================= +// Round-trip via OCI layout on disk. +// ============================================================================= + +#[test] +fn round_trip_save_load_call() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let oci = dir.path().join("snap"); + snapshot.to_oci(&oci, "latest").unwrap(); + + let loaded = Snapshot::from_oci(&oci, "latest").unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + + let result: String = sbox2.call("Echo", "hello\n".to_string()).unwrap(); + assert_eq!(result, "hello\n"); +} + +#[test] +fn snapshot_and_pt_size_round_trip() { + // Running-sandbox snapshot. + let mut sbox = create_test_sandbox(); + let snap = sbox.snapshot().unwrap(); + let original_snapshot_size = snap.layout().snapshot_size; + let original_pt_size = snap.layout().pt_size; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("running"); + snap.to_oci(&path, "latest").unwrap(); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + assert_eq!(loaded.layout().snapshot_size, original_snapshot_size); + assert_eq!(loaded.layout().pt_size, original_pt_size); + + // Pre-init snapshot. + let preinit = create_snapshot_from_binary(); + let preinit_snapshot_size = preinit.layout().snapshot_size; + let preinit_pt_size = preinit.layout().pt_size; + + let path = dir.path().join("preinit"); + preinit.to_oci(&path, "latest").unwrap(); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + assert_eq!(loaded.layout().snapshot_size, preinit_snapshot_size); + assert_eq!(loaded.layout().pt_size, preinit_pt_size); +} + +#[test] +fn pre_init_snapshot_save_load() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("preinit"); + snap.to_oci(&path, "latest").unwrap(); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let mut sbox = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); +} + +// ============================================================================= +// Restore semantics (id/generation gating). +// ============================================================================= + +#[test] +fn restore_from_loaded_snapshot() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + let loaded = Arc::new(Snapshot::from_oci(&path, "latest").unwrap()); + let mut sbox2 = + MultiUseSandbox::from_snapshot(loaded.clone(), HostFunctions::default(), None).unwrap(); + + sbox2.call::("AddToStatic", 5i32).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 5); + + sbox2.restore(loaded).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 0); +} + +#[test] +fn restore_to_different_oci_loaded_snapshot_rejected() { + let mut sbox = create_test_sandbox(); + let snap1 = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let p1 = dir.path().join("snap1"); + snap1.to_oci(&p1, "latest").unwrap(); + let p2 = dir.path().join("snap2"); + // Two `from_oci` calls of the same image yield distinct + // `sandbox_id`s, so a sandbox built from snap2 must refuse to + // restore to a snapshot loaded as snap1 (different id). + snap1.to_oci(&p2, "latest").unwrap(); + + let loaded1 = Arc::new(Snapshot::from_oci(&p1, "latest").unwrap()); + let loaded2 = Arc::new(Snapshot::from_oci(&p2, "latest").unwrap()); + + let mut sbox = MultiUseSandbox::from_snapshot(loaded2, HostFunctions::default(), None).unwrap(); + let err = sbox.restore(loaded1).unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("Snapshot") || msg.contains("snapshot") || msg.contains("Mismatch"), + "expected sandbox/snapshot mismatch error, got: {}", + msg + ); +} + +#[test] +fn many_sandboxes_share_single_arc_snapshot() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + let loaded = Arc::new(Snapshot::from_oci(&path, "latest").unwrap()); + let mut sandboxes = Vec::new(); + for _ in 0..4 { + sandboxes.push( + MultiUseSandbox::from_snapshot(loaded.clone(), HostFunctions::default(), None).unwrap(), + ); + } + for sbox in sandboxes.iter_mut() { + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + } +} + +#[test] +fn concurrent_sandboxes_from_same_oci() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + let path = std::sync::Arc::new(path); + let mut handles = Vec::new(); + for _ in 0..4 { + let p = path.clone(); + handles.push(std::thread::spawn(move || { + let loaded = Snapshot::from_oci(p.as_ref(), "latest").unwrap(); + let mut sbox = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None) + .unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + })); + } + for h in handles { + h.join().unwrap(); + } +} + +#[test] +fn cow_does_not_mutate_backing_file() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + // Hash every blob file to verify nothing changes after a CoW write + // through the loaded sandbox. + let blobs_dir = path.join("blobs").join("sha256"); + let snapshot_before: std::collections::BTreeMap<_, _> = std::fs::read_dir(&blobs_dir) + .unwrap() + .map(|e| { + let e = e.unwrap(); + let bytes = std::fs::read(e.path()).unwrap(); + (e.file_name(), bytes) + }) + .collect(); + + { + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let mut sbox = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None) + .unwrap(); + sbox.call::("AddToStatic", 99).unwrap(); + } + + let snapshot_after: std::collections::BTreeMap<_, _> = std::fs::read_dir(&blobs_dir) + .unwrap() + .map(|e| { + let e = e.unwrap(); + let bytes = std::fs::read(e.path()).unwrap(); + (e.file_name(), bytes) + }) + .collect(); + assert_eq!( + snapshot_before, snapshot_after, + "CoW writes must not mutate any blob in the OCI layout" + ); +} + +// ============================================================================= +// Architecture / hypervisor / ABI gating. +// ============================================================================= + +/// Compute sha256 of `bytes` and return the lowercase hex digest. +fn sha256_hex(bytes: &[u8]) -> String { + let arr: [u8; 32] = Sha256::digest(bytes).into(); + hex::encode(arr) +} + +fn rewrite_config(oci_dir: &std::path::Path, mutate: F) { + // Mutate the config blob and rewrite the manifest + index so the + // OCI layout stays self-consistent: blob filenames, descriptor + // sizes, and descriptor sha256 digests all match the current + // bytes on disk. The point of these helpers is to exercise + // field-level validators (arch, abi_version, hypervisor, etc.), + // not the digest layer; tests that want to probe the digest + // layer write raw bytes directly. + let cfg_path = find_config_blob(oci_dir); + let mut cfg: Value = serde_json::from_slice(&std::fs::read(&cfg_path).unwrap()).unwrap(); + mutate(&mut cfg); + let new_cfg_bytes = serde_json::to_vec_pretty(&cfg).unwrap(); + let new_cfg_hex = sha256_hex(&new_cfg_bytes); + let blobs_dir = oci_dir.join("blobs").join("sha256"); + let new_cfg_path = blobs_dir.join(&new_cfg_hex); + std::fs::write(&new_cfg_path, &new_cfg_bytes).unwrap(); + if new_cfg_path != cfg_path { + std::fs::remove_file(&cfg_path).ok(); + } + + let mp = manifest_path(oci_dir); + let mut manifest: Value = serde_json::from_slice(&std::fs::read(&mp).unwrap()).unwrap(); + manifest["config"]["digest"] = Value::from(format!("sha256:{}", new_cfg_hex)); + manifest["config"]["size"] = Value::from(new_cfg_bytes.len() as u64); + let new_manifest_bytes = serde_json::to_vec_pretty(&manifest).unwrap(); + let new_manifest_hex = sha256_hex(&new_manifest_bytes); + let new_manifest_path = blobs_dir.join(&new_manifest_hex); + std::fs::write(&new_manifest_path, &new_manifest_bytes).unwrap(); + if new_manifest_path != mp { + std::fs::remove_file(&mp).ok(); + } + + let index_path = oci_dir.join("index.json"); + let mut index: Value = serde_json::from_slice(&std::fs::read(&index_path).unwrap()).unwrap(); + index["manifests"][0]["digest"] = Value::from(format!("sha256:{}", new_manifest_hex)); + index["manifests"][0]["size"] = Value::from(new_manifest_bytes.len() as u64); + std::fs::write(index_path, serde_json::to_vec_pretty(&index).unwrap()).unwrap(); +} + +/// Locate the manifest blob path inside `oci_dir`. +fn manifest_path(oci_dir: &std::path::Path) -> std::path::PathBuf { + let index: Value = + serde_json::from_slice(&std::fs::read(oci_dir.join("index.json")).unwrap()).unwrap(); + let digest = index["manifests"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap() + .to_string(); + oci_dir.join("blobs").join("sha256").join(digest) +} + +/// Mutate the on-disk manifest JSON. Updates the index's manifest +/// descriptor `size` and `digest` to match the new manifest bytes +/// so the test exercises the field-level validator we care about, +/// not the digest layer. +fn rewrite_manifest(oci_dir: &std::path::Path, mutate: F) { + let mp = manifest_path(oci_dir); + let mut manifest: Value = serde_json::from_slice(&std::fs::read(&mp).unwrap()).unwrap(); + mutate(&mut manifest); + let new_bytes = serde_json::to_vec_pretty(&manifest).unwrap(); + let new_hex = sha256_hex(&new_bytes); + let blobs_dir = oci_dir.join("blobs").join("sha256"); + let new_path = blobs_dir.join(&new_hex); + std::fs::write(&new_path, &new_bytes).unwrap(); + if new_path != mp { + std::fs::remove_file(&mp).ok(); + } + + let index_path = oci_dir.join("index.json"); + let mut index: Value = serde_json::from_slice(&std::fs::read(&index_path).unwrap()).unwrap(); + index["manifests"][0]["digest"] = Value::from(format!("sha256:{}", new_hex)); + index["manifests"][0]["size"] = Value::from(new_bytes.len() as u64); + std::fs::write(index_path, serde_json::to_vec_pretty(&index).unwrap()).unwrap(); +} + +/// Mutate the on-disk index JSON in place. The index is the root of +/// the OCI layout and is not itself referenced by any digest, so +/// nothing further needs to be updated. +fn rewrite_index(oci_dir: &std::path::Path, mutate: F) { + let path = oci_dir.join("index.json"); + let mut index: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + mutate(&mut index); + std::fs::write(path, serde_json::to_vec_pretty(&index).unwrap()).unwrap(); +} + +#[test] +fn arch_mismatch_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + rewrite_config(&path, |cfg| { + cfg["arch"] = Value::from("aarch64"); + }); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("architecture") || msg.contains("arch"), + "expected architecture mismatch, got: {}", + msg + ); +} + +#[test] +fn abi_version_mismatch_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + rewrite_config(&path, |cfg| { + cfg["abi_version"] = Value::from(9999u32); + }); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("ABI") || msg.contains("abi"), + "expected ABI version mismatch, got: {}", + msg + ); +} + +#[test] +fn hypervisor_mismatch_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + // Pick a hypervisor that is not the current one. + let current = cfg_current_hypervisor(); + let other = if current == "kvm" { "mshv" } else { "kvm" }; + + rewrite_config(&path, |cfg| { + cfg["hypervisor"] = Value::from(other); + }); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("hypervisor"), + "expected hypervisor mismatch, got: {}", + msg + ); +} + +fn cfg_current_hypervisor() -> &'static str { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("probe"); + create_snapshot_from_binary() + .to_oci(&path, "latest") + .unwrap(); + let cfg_path = find_config_blob(&path); + let cfg: Value = serde_json::from_slice(&std::fs::read(&cfg_path).unwrap()).unwrap(); + match cfg["hypervisor"].as_str().unwrap() { + "kvm" => "kvm", + "mshv" => "mshv", + "whp" => "whp", + other => panic!("unknown hypervisor tag {other}"), + } +} + +// ============================================================================= +// Entrypoint vs sregs invariants enforced by serde shape. +// ============================================================================= + +#[test] +fn call_snapshot_without_sregs_rejected() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + // Strip sregs from the entrypoint variant. serde must reject the + // missing field at parse time. + rewrite_config(&path, |cfg| { + let entry = cfg["entrypoint"].as_object_mut().unwrap(); + assert_eq!(entry["kind"].as_str().unwrap(), "call"); + entry.remove("sregs"); + }); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("sregs") || msg.contains("missing field") || msg.contains("config"), + "expected serde error about missing sregs, got: {}", + msg + ); +} + +#[test] +fn initialise_snapshot_with_sregs_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + // Add a bogus sregs field to the Initialise variant. serde must + // reject the unknown field (variant has deny_unknown_fields). + rewrite_config(&path, |cfg| { + let entry = cfg["entrypoint"].as_object_mut().unwrap(); + assert_eq!(entry["kind"].as_str().unwrap(), "initialise"); + entry.insert("sregs".to_string(), Value::from("{}")); + }); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("sregs") || msg.contains("unknown field") || msg.contains("config"), + "expected serde error about unknown field sregs, got: {}", + msg + ); +} + +// ============================================================================= +// Host functions validation. +// +// `validate_host_functions` enforces a superset relation: every host +// function registered when the snapshot was taken must be present in +// the loaded sandbox's `HostFunctions` with a matching signature. +// Extras in the loaded set are allowed. +// ============================================================================= + +/// Build a `MultiUseSandbox` with the default host functions plus a +/// custom `Add(i32, i32) -> i32`. Used to seed the snapshot side of +/// the host-function validation tests so the snapshot has a +/// non-default required function. +fn create_sandbox_with_custom_host_funcs() -> MultiUseSandbox { + let path = simple_guest_as_string().unwrap(); + let mut u = UninitializedSandbox::new(GuestBinary::FilePath(path), None).unwrap(); + u.register_host_function("Add", |a: i32, b: i32| Ok(a + b)) + .unwrap(); + u.evolve().unwrap() +} + +/// `HostFunctions::default()` plus a matching `Add(i32, i32) -> i32`. +fn host_funcs_with_matching_add() -> HostFunctions { + let mut hf = HostFunctions::default(); + hf.register_host_function("Add", |a: i32, b: i32| Ok(a + b)) + .unwrap(); + hf +} + +#[test] +fn from_snapshot_accepts_matching_host_functions() { + let mut sbox = create_sandbox_with_custom_host_funcs(); + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.to_oci(&path, "latest").unwrap(); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(Arc::new(loaded), host_funcs_with_matching_add(), None) + .unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 0); +} + +#[test] +fn from_snapshot_rejects_missing_host_function() { + // Snapshot was taken with `Add` registered. Loading with the + // default `HostFunctions` (no `Add`) must be rejected. + let mut sbox = create_sandbox_with_custom_host_funcs(); + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.to_oci(&path, "latest").unwrap(); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let err = MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None) + .expect_err("from_snapshot must reject a HostFunctions set missing `Add`"); + let msg = format!("{}", err); + assert!( + msg.contains("missing") && msg.contains("Add"), + "expected missing-host-function error mentioning Add, got: {}", + msg + ); +} + +#[test] +fn from_snapshot_rejects_signature_mismatch() { + // Snapshot has `Add(i32, i32) -> i32`. Load registers an `Add` + // with a different signature. validate_host_functions must + // refuse the mismatch. + let mut sbox = create_sandbox_with_custom_host_funcs(); + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.to_oci(&path, "latest").unwrap(); + + let mut hf = HostFunctions::default(); + hf.register_host_function("Add", |a: String, b: String| Ok(format!("{a}{b}"))) + .unwrap(); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let err = MultiUseSandbox::from_snapshot(Arc::new(loaded), hf, None) + .expect_err("from_snapshot must reject a signature mismatch on Add"); + let msg = format!("{}", err); + assert!( + msg.contains("signature_mismatches") && msg.contains("Add"), + "expected signature-mismatch error mentioning Add, got: {}", + msg + ); +} + +#[test] +fn from_snapshot_accepts_extra_host_functions() { + // Snapshot has `Add`. Load registers `Add` (matching) plus an + // unrelated `Mul`. Extras are allowed. + let mut sbox = create_sandbox_with_custom_host_funcs(); + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.to_oci(&path, "latest").unwrap(); + + let mut hf = host_funcs_with_matching_add(); + hf.register_host_function("Mul", |a: i32, b: i32| Ok(a * b)) + .unwrap(); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let mut sbox2 = MultiUseSandbox::from_snapshot(Arc::new(loaded), hf, None).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 0); +} + +#[test] +fn from_snapshot_accepts_zero_arg_host_function() { + // A zero-arg host function must round-trip through OCI. + let path = simple_guest_as_string().unwrap(); + let mut u = UninitializedSandbox::new(GuestBinary::FilePath(path), None).unwrap(); + u.register_host_function("Zero", || Ok(7i64)).unwrap(); + let mut sbox = u.evolve().unwrap(); + + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.to_oci(&path, "latest").unwrap(); + + let mut hf = HostFunctions::default(); + hf.register_host_function("Zero", || Ok(7i64)).unwrap(); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let _sbox2 = MultiUseSandbox::from_snapshot(Arc::new(loaded), hf, None) + .expect("zero-arg host function must round-trip through OCI"); +} + +#[test] +fn from_snapshot_has_default_host_print() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + let _ = sbox2.call::("PrintTwoArgs", ("hi".to_string(), 42i32)); +} + +// ============================================================================= +// OCI-shape invariants. +// ============================================================================= + +#[test] +fn missing_oci_layout_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + std::fs::remove_file(path.join("oci-layout")).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("oci-layout"), + "expected missing oci-layout error, got: {}", + msg + ); +} + +#[test] +fn wrong_image_layout_version_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + std::fs::write( + path.join("oci-layout"), + r#"{"imageLayoutVersion":"99.0.0"}"#, + ) + .unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("image layout version") || msg.contains("imageLayoutVersion"), + "expected layout version error, got: {}", + msg + ); +} + +#[test] +fn missing_index_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + std::fs::remove_file(path.join("index.json")).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("index.json"), + "expected missing index.json error, got: {}", + msg + ); +} + +#[test] +fn snapshot_blob_size_mismatch_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + // Truncate the snapshot blob by one byte. + let blobs_dir = path.join("blobs").join("sha256"); + let manifest_bytes = std::fs::read(path.join("index.json")).unwrap(); + let index: Value = serde_json::from_slice(&manifest_bytes).unwrap(); + let manifest_digest = index["manifests"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + let manifest_path = blobs_dir.join(manifest_digest); + let manifest: Value = serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); + let snap_digest = manifest["layers"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + let snap_path = blobs_dir.join(snap_digest); + let bytes = std::fs::read(&snap_path).unwrap(); + std::fs::write(&snap_path, &bytes[..bytes.len() - 1]).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("size") || msg.contains("mismatch"), + "expected size mismatch error, got: {}", + msg + ); +} + +#[test] +fn snapshot_layout_snapshot_size_zero_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + rewrite_config(&path, |cfg| { + cfg["layout"]["snapshot_size"] = Value::from(0u64); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("snapshot_size"), + "expected snapshot_size error, got: {}", + msg + ); +} + +#[test] +fn snapshot_layout_snapshot_size_unaligned_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + rewrite_config(&path, |cfg| { + let s = cfg["layout"]["snapshot_size"].as_u64().unwrap(); + cfg["layout"]["snapshot_size"] = Value::from(s + 1); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("PAGE_SIZE") || msg.contains("multiple"), + "expected page alignment error, got: {}", + msg + ); +} + +#[test] +fn snapshot_layout_snapshot_size_must_match_memory_size() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + let page = hyperlight_common::vmem::PAGE_SIZE as u64; + rewrite_config(&path, |cfg| { + let m = cfg["memory_size"].as_u64().unwrap(); + cfg["layout"]["snapshot_size"] = Value::from(m + page); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("does not equal memory_size"), + "expected snapshot_size + pt_size != memory_size error, got: {}", + msg + ); +} + +#[test] +fn snapshot_layout_pt_size_unaligned_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + rewrite_config(&path, |cfg| { + if let Some(p) = cfg["layout"]["pt_size"].as_u64() { + cfg["layout"]["pt_size"] = Value::from(p + 1); + } else { + cfg["layout"]["pt_size"] = Value::from(1u64); + } + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("pt_size") || msg.contains("PAGE_SIZE") || msg.contains("multiple"), + "expected pt_size validation error, got: {}", + msg + ); +} + +#[test] +fn missing_snapshot_blob_rejected() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + let blobs_dir = path.join("blobs").join("sha256"); + let manifest_bytes = std::fs::read(path.join("index.json")).unwrap(); + let index: Value = serde_json::from_slice(&manifest_bytes).unwrap(); + let manifest_digest = index["manifests"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + let manifest_path = blobs_dir.join(manifest_digest); + let manifest: Value = serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap(); + let snap_digest = manifest["layers"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap(); + std::fs::remove_file(blobs_dir.join(snap_digest)).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("snapshot blob") || msg.contains("No such") || msg.contains("not found"), + "expected missing-blob error, got: {}", + msg + ); +} + +// ============================================================================= +// Path semantics. +// ============================================================================= + +#[test] +fn from_oci_nonexistent_path_returns_error() { + let err = unwrap_err_snapshot(Snapshot::from_oci("/nonexistent/path/to/oci", "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("stat") || msg.contains("No such") || msg.contains("not found"), + "expected missing-path error, got: {}", + msg + ); +} + +#[test] +fn from_oci_file_not_directory_rejected() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("not-a-dir"); + std::fs::write(&file_path, b"hello").unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&file_path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("not a directory"), + "expected not-a-directory error, got: {}", + msg + ); +} + +#[test] +fn to_oci_refuses_existing_non_oci_directory() { + // The contract is: `to_oci` errors out if a non-OCI-layout + // directory exists at `path`, without modifying it. The caller + // is responsible for cleaning up the previous directory (or + // choosing a different path). This eliminates the foot-gun of + // `remove_dir_all` wiping arbitrary user data on a typo. + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + std::fs::create_dir(&path).unwrap(); + std::fs::write(path.join("stale.txt"), b"do-not-delete").unwrap(); + + let err = snap.to_oci(&path, "latest").unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("not an OCI image layout"), + "expected non-OCI-layout refusal error, got: {}", + msg + ); + + // Unrelated content must survive untouched. + assert_eq!( + std::fs::read(path.join("stale.txt")).unwrap(), + b"do-not-delete", + "to_oci must not delete unrelated files in the target directory" + ); +} + +#[test] +fn to_oci_refuses_existing_file() { + // Same contract for a regular file at `path`: refuse without + // touching it. + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + std::fs::write(&path, b"i am a file, not a snapshot").unwrap(); + + let err = snap.to_oci(&path, "latest").unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("not a directory"), + "expected refusal-on-non-directory error, got: {}", + msg + ); + assert_eq!( + std::fs::read(&path).unwrap(), + b"i am a file, not a snapshot", + "to_oci must not touch a pre-existing file at the target path" + ); +} + +#[test] +fn to_oci_refuses_duplicate_tag() { + // Saving the same tag twice into one layout is rejected: the + // caller has to delete the existing tag first, or pick a + // different name. This avoids accidental in-place replacement. + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.to_oci(&path, "latest").unwrap(); + + let err = snap.to_oci(&path, "latest").unwrap_err(); + let msg = format!("{}", err); + assert!( + msg.contains("refusing to overwrite tag") && msg.contains("\"latest\""), + "expected duplicate-tag refusal error, got: {}", + msg + ); + // The first tag must still be loadable. + let _ = Snapshot::from_oci(&path, "latest").unwrap(); +} + +/// Asserts the integrity contract: a snapshot blob whose bytes have +/// been replaced (without changing length, so descriptor sizes still +/// match) must be rejected on load via digest mismatch. +#[test] +fn from_oci_rejects_snapshot_blob_byte_mutation() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + // Locate the snapshot blob via the manifest, then flip one byte + // somewhere in the middle. Length is preserved so all descriptor + // size checks still pass. Only a digest re-hash can detect this. + let blobs_dir = path.join("blobs").join("sha256"); + let index: Value = + serde_json::from_slice(&std::fs::read(path.join("index.json")).unwrap()).unwrap(); + let manifest_digest = index["manifests"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap() + .to_string(); + let manifest: Value = + serde_json::from_slice(&std::fs::read(blobs_dir.join(&manifest_digest)).unwrap()).unwrap(); + let snap_digest = manifest["layers"][0]["digest"] + .as_str() + .unwrap() + .strip_prefix("sha256:") + .unwrap() + .to_string(); + let snap_path = blobs_dir.join(&snap_digest); + let mut bytes = std::fs::read(&snap_path).unwrap(); + let mid = bytes.len() / 2; + bytes[mid] ^= 0xFF; + std::fs::write(&snap_path, &bytes).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("digest") || msg.contains("hash") || msg.contains("sha256"), + "expected digest-mismatch error, got: {}", + msg + ); +} + +/// Same idea as `from_oci_rejects_snapshot_blob_byte_mutation`, but +/// targeting the config blob. A config-blob mutation that preserves +/// the descriptor size and the structural fields the loader +/// validates today (e.g. flipping a byte inside the host-function +/// flatbuffer payload) must be caught by digest verification. +#[test] +fn from_oci_rejects_config_blob_byte_mutation() { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + let cfg_path = find_config_blob(&path); + let mut bytes = std::fs::read(&cfg_path).unwrap(); + // Replace the first ASCII brace `{` with a different byte that + // keeps the file the same length but yields a different sha256. + // This will also break JSON parsing, but the point is to assert + // the digest layer rejects it before the parser ever runs. + bytes[0] = b' '; + std::fs::write(&cfg_path, &bytes).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("digest") || msg.contains("hash") || msg.contains("sha256"), + "expected digest-mismatch error, got: {}", + msg + ); +} + +#[test] +fn from_oci_observes_per_path_contents() { + // `to_oci` no longer permits overwriting, so verifying that two + // independent saves have independent contents is done by writing + // each snapshot to its own path and asserting the loaded + // contents differ. + let mut sbox = create_test_sandbox(); + sbox.call::("AddToStatic", 11i32).unwrap(); + let snap_x = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let path_x = dir.path().join("snap_x"); + snap_x.to_oci(&path_x, "latest").unwrap(); + + let loaded_x = Snapshot::from_oci(&path_x, "latest").unwrap(); + let mut sbox_x = + MultiUseSandbox::from_snapshot(Arc::new(loaded_x), HostFunctions::default(), None).unwrap(); + assert_eq!(sbox_x.call::("GetStatic", ()).unwrap(), 11); + + sbox.call::("AddToStatic", 44i32).unwrap(); + let snap_y = sbox.snapshot().unwrap(); + let path_y = dir.path().join("snap_y"); + snap_y.to_oci(&path_y, "latest").unwrap(); + + let loaded_y = Snapshot::from_oci(&path_y, "latest").unwrap(); + let mut sbox_y = + MultiUseSandbox::from_snapshot(Arc::new(loaded_y), HostFunctions::default(), None).unwrap(); + assert_eq!(sbox_y.call::("GetStatic", ()).unwrap(), 55); +} + +// ============================================================================= +// Exhaustive input-validation tests for `from_oci`. +// +// Every load-side error path in `super::file::from_oci` should be +// exercised here. +// ============================================================================= + +fn save_for_mutation() -> (tempfile::TempDir, std::path::PathBuf) { + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + (dir, path) +} + +fn assert_err_contains(err: crate::HyperlightError, needle: &str) { + let msg = format!("{}", err); + assert!( + msg.contains(needle), + "expected error to contain {:?}, got: {}", + needle, + msg + ); +} + +#[test] +fn malformed_oci_layout_rejected() { + let (_dir, path) = save_for_mutation(); + std::fs::write(path.join("oci-layout"), b"not-valid-json{").unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "oci-layout"); +} + +#[test] +fn oci_layout_missing_version_field_rejected() { + let (_dir, path) = save_for_mutation(); + std::fs::write(path.join("oci-layout"), r#"{"unrelated":"field"}"#).unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "imageLayoutVersion"); +} + +#[test] +fn malformed_index_json_rejected() { + let (_dir, path) = save_for_mutation(); + std::fs::write(path.join("index.json"), b"{not json").unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "index.json"); +} + +#[test] +fn empty_index_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_index(&path, |idx| { + idx["manifests"] = Value::Array(Vec::new()); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "no manifest tagged"); +} + +#[test] +fn from_oci_rejects_duplicate_tag_in_index() { + // A valid OCI layout has unique tags. Two manifests sharing the + // same `org.opencontainers.image.ref.name` annotation is + // malformed and from_oci must refuse rather than silently + // pick one. + let (_dir, path) = save_for_mutation(); + rewrite_index(&path, |idx| { + let first = idx["manifests"][0].clone(); + idx["manifests"].as_array_mut().unwrap().push(first); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "multiple manifests tagged"); +} + +#[test] +fn missing_manifest_blob_rejected() { + let (_dir, path) = save_for_mutation(); + std::fs::remove_file(manifest_path(&path)).unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("open") || msg.contains("No such") || msg.contains("not found"), + "expected missing-manifest error, got: {}", + msg + ); +} + +#[test] +fn bad_digest_format_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_index(&path, |idx| { + // Strip the algorithm prefix entirely. `oci-spec` validates + // descriptor digests on parse, so the index parser rejects + // this before our own digest helper sees it. + idx["manifests"][0]["digest"] = Value::from("deadbeef"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("digest") || msg.contains("index.json"), + "expected digest or parse error, got: {}", + msg + ); +} + +#[test] +fn malformed_manifest_json_rejected() { + // Probes the manifest JSON parser. Under `from_oci`, the + // digest-verification step would fire first and short-circuit + // this; that path is covered by + // `from_oci_rejects_manifest_blob_byte_mutation`. Use + // `from_oci_unchecked` here to reach the parser. + let (_dir, path) = save_for_mutation(); + let mp = manifest_path(&path); + std::fs::write(&mp, b"{not json").unwrap(); + // Update index size to match so we hit the JSON parser, not the + // size check. + let new_len = std::fs::metadata(&mp).unwrap().len(); + rewrite_index(&path, |idx| { + idx["manifests"][0]["size"] = Value::from(new_len); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&path, "latest")); + assert_err_contains(err, "manifest"); +} + +#[test] +fn wrong_manifest_schema_version_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + m["schemaVersion"] = Value::from(99u32); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "schemaVersion"); +} + +#[test] +fn unknown_config_media_type_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + m["config"]["mediaType"] = Value::from("application/vnd.example.unknown.v1+json"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "config media type"); +} + +#[test] +fn empty_layers_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + m["layers"] = Value::Array(Vec::new()); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "layer"); +} + +#[test] +fn extra_layers_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + let first = m["layers"][0].clone(); + m["layers"].as_array_mut().unwrap().push(first); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "layer"); +} + +#[test] +fn unknown_snapshot_layer_media_type_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + m["layers"][0]["mediaType"] = Value::from("application/vnd.example.unknown.v1"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "snapshot layer media type"); +} + +/// Manifest- and index-level annotations injected by third-party +/// tools (cosign, ORAS, build pipelines, etc.) must NOT break load. +/// `HlConfig` is intentionally strict (`deny_unknown_fields`) but +/// the OCI envelope around it is parsed via `oci-spec`'s lenient +/// types. +#[test] +fn manifest_and_index_annotations_tolerated() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + rewrite_manifest(&path, |m| { + let mut anns = serde_json::Map::new(); + anns.insert( + "org.opencontainers.image.created".to_string(), + Value::from("2024-01-01T00:00:00Z"), + ); + anns.insert( + "dev.sigstore.cosign/signature".to_string(), + Value::from("MEUCIQDsignature"), + ); + m["annotations"] = Value::Object(anns); + }); + rewrite_index(&path, |idx| { + let mut anns = serde_json::Map::new(); + anns.insert( + "org.opencontainers.image.ref.name".to_string(), + Value::from("v1.2.3"), + ); + idx["annotations"] = Value::Object(anns); + }); + + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + assert_eq!(sbox2.call::("GetStatic", ()).unwrap(), 0); +} + +#[test] +fn config_blob_size_descriptor_mismatch_rejected() { + let (_dir, path) = save_for_mutation(); + // Bump the config descriptor's claimed size by one without + // touching the actual blob. + rewrite_manifest(&path, |m| { + let sz = m["config"]["size"].as_u64().unwrap(); + m["config"]["size"] = Value::from(sz + 1); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "config blob size mismatch"); +} + +#[test] +fn malformed_config_json_rejected() { + // Probes the config JSON parser. Under `from_oci` the + // digest-verification step would fire first; that path is + // covered by `from_oci_rejects_config_blob_byte_mutation`. + // Use `from_oci_unchecked` here to reach the parser. + let (_dir, path) = save_for_mutation(); + let cfg_path = find_config_blob(&path); + std::fs::write(&cfg_path, b"{not json").unwrap(); + // Update both the manifest's config descriptor size and the + // index's manifest descriptor size to match so we reach the + // JSON parser, not the size check. + let new_cfg_len = std::fs::metadata(&cfg_path).unwrap().len(); + rewrite_manifest(&path, |m| { + m["config"]["size"] = Value::from(new_cfg_len); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&path, "latest")); + assert_err_contains(err, "config JSON"); +} + +#[test] +fn memory_size_zero_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + cfg["memory_size"] = Value::from(0u64); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "memory_size"); +} + +#[test] +fn memory_size_unaligned_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + let sz = cfg["memory_size"].as_u64().unwrap(); + cfg["memory_size"] = Value::from(sz + 1); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{}", err); + // Either the page-alignment check or the file-size check trips. + // Both are valid signals that the value was rejected. + assert!( + msg.contains("memory_size") || msg.contains("PAGE_SIZE") || msg.contains("size"), + "expected memory_size rejection, got: {}", + msg + ); +} + +#[test] +fn bad_init_data_permissions_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + // 1u32 << 31 is well outside the defined READ|WRITE|EXECUTE bits. + cfg["layout"]["init_data_permissions"] = Value::from(0x8000_0000u32); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "init_data_permissions"); +} + +#[test] +fn entrypoint_addr_outside_snapshot_region_rejected() { + // A crafted config can claim any u64 as the entry point. The + // loader must refuse addresses that don't lie within + // [BASE_ADDRESS, BASE_ADDRESS + snapshot_size) so a malicious + // image can't direct execution into unmapped GPA space or into + // the writable scratch region. + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + let entry = cfg["entrypoint"].as_object_mut().unwrap(); + // 0xDEAD_BEEF_0000 is far above any plausible snapshot + // region (snapshot_size is bounded by MAX_MEMORY_SIZE, + // ~16 GiB) and outside guest mapped memory. + entry["addr"] = Value::from(0xDEAD_BEEF_0000u64); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "entrypoint addr"); +} + +#[test] +fn entrypoint_addr_below_base_address_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + let entry = cfg["entrypoint"].as_object_mut().unwrap(); + // 0 is below BASE_ADDRESS (0x1000); rejected as "outside the + // snapshot region". + entry["addr"] = Value::from(0u64); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "entrypoint addr"); +} + +// ============================================================================= +// `from_oci_unchecked`: skips blob digest verification but still runs +// every other validator (OCI structure, descriptor sizes, schema +// versions, arch / hypervisor / ABI tags, layout bounds, entrypoint +// bounds). +// ============================================================================= + +#[test] +fn from_oci_unchecked_round_trips() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + let loaded = Snapshot::from_oci_unchecked(&path, "latest").unwrap(); + let mut sbox2 = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); + let result: String = sbox2.call("Echo", "hi\n".to_string()).unwrap(); + assert_eq!(result, "hi\n"); +} + +#[test] +fn from_oci_unchecked_still_validates_config_fields() { + // Field-level validators (arch, abi, hypervisor, layout bounds, + // entrypoint bounds) must still fire under `from_oci_unchecked`. + // Use `rewrite_config` so the layout stays self-consistent + // (otherwise the checked path would also catch this via the + // descriptor-size check before the field validator runs). + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + cfg["arch"] = Value::from("aarch64"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&path, "latest")); + let msg = format!("{}", err); + assert!( + msg.contains("architecture") || msg.contains("arch"), + "expected architecture mismatch under from_oci_unchecked, got: {}", + msg + ); +} + +#[test] +fn from_oci_rejects_manifest_blob_byte_mutation() { + // Mutate a manifest body byte (without updating the index's + // descriptor digest) and confirm the loader catches it via + // digest verification before any of the field-level manifest + // validators (schema version, media type, etc.) run. + let snapshot = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snapshot.to_oci(&path, "latest").unwrap(); + + let mp = manifest_path(&path); + let mut bytes = std::fs::read(&mp).unwrap(); + // Flip the first byte. Length is preserved so the descriptor + // size check still passes; only digest verification can detect + // this. The byte will also break JSON parsing, but the digest + // check fires first. + bytes[0] ^= 0x20; + std::fs::write(&mp, &bytes).unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "digest mismatch"); +} + +// ============================================================================= +// Multi-tag layouts. +// +// One OCI Image Layout directory can hold any number of snapshots, +// each addressed by tag. Blobs (manifest, config, snapshot memory) +// are deduplicated across tags by content digest. +// ============================================================================= + +#[test] +fn append_distinct_tags_to_one_layout() { + // Two distinguishable snapshots saved into the same layout + // under different tags. Each is loadable by its own tag and has + // the right contents. + let mut sbox = create_test_sandbox(); + + sbox.call::("AddToStatic", 7i32).unwrap(); + let snap_a = sbox.snapshot().unwrap(); + + sbox.call::("AddToStatic", 30i32).unwrap(); + let snap_b = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap_a.to_oci(&path, "snap-a").unwrap(); + snap_b.to_oci(&path, "snap-b").unwrap(); + + let loaded_a = Snapshot::from_oci(&path, "snap-a").unwrap(); + let loaded_b = Snapshot::from_oci(&path, "snap-b").unwrap(); + + let mut sbox_a = + MultiUseSandbox::from_snapshot(Arc::new(loaded_a), HostFunctions::default(), None).unwrap(); + let mut sbox_b = + MultiUseSandbox::from_snapshot(Arc::new(loaded_b), HostFunctions::default(), None).unwrap(); + assert_eq!(sbox_a.call::("GetStatic", ()).unwrap(), 7); + assert_eq!(sbox_b.call::("GetStatic", ()).unwrap(), 37); +} + +#[test] +fn appending_dedupes_identical_blobs() { + // Two saves of the SAME snapshot under different tags must not + // duplicate any blob in `blobs/sha256/`. Every blob in the + // layout must be the union of the per-tag manifest, config, and + // snapshot blob digests, but with each unique digest counted + // once. + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "v1").unwrap(); + snap.to_oci(&path, "v2").unwrap(); + + let blobs_dir = path.join("blobs").join("sha256"); + let blob_count = std::fs::read_dir(&blobs_dir).unwrap().count(); + // Identical snapshots share the snapshot blob, the config blob, + // and the manifest blob; only the index distinguishes them. + // Therefore exactly 3 blobs. + assert_eq!( + blob_count, 3, + "expected 3 blobs (snapshot + config + manifest, all dedup'd) in {:?}", + blobs_dir + ); + + // Both tags load and produce sandboxes with the same state. + let _ = Snapshot::from_oci(&path, "v1").unwrap(); + let _ = Snapshot::from_oci(&path, "v2").unwrap(); +} + +#[test] +fn appending_distinct_snapshots_dedups_shared_blobs() { + // Two DIFFERENT snapshots saved under different tags. Each has + // its own snapshot memory blob and its own manifest blob, but + // the config blob (which captures arch / hypervisor / layout / + // host functions / entrypoint, but not guest memory) is + // typically identical across snapshots from the same sandbox, + // so we expect 5 blobs: 2 memory + 1 shared config + 2 + // manifests. + let mut sbox = create_test_sandbox(); + sbox.call::("AddToStatic", 1i32).unwrap(); + let snap_a = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 2i32).unwrap(); + let snap_b = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap_a.to_oci(&path, "a").unwrap(); + snap_b.to_oci(&path, "b").unwrap(); + + let blobs_dir = path.join("blobs").join("sha256"); + let blob_count = std::fs::read_dir(&blobs_dir).unwrap().count(); + assert_eq!( + blob_count, 5, + "expected 5 distinct blobs (2 memory + 1 shared config + 2 manifests) in {:?}", + blobs_dir + ); +} + +#[test] +fn from_oci_unknown_tag_lists_available_tags() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "alpha").unwrap(); + snap.to_oci(&path, "beta").unwrap(); + + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "missing")); + let msg = format!("{}", err); + assert!( + msg.contains("no manifest tagged") && msg.contains("\"missing\""), + "expected unknown-tag error mentioning the requested tag, got: {}", + msg + ); + assert!( + msg.contains("alpha") && msg.contains("beta"), + "expected available-tags listing, got: {}", + msg + ); +} + +#[test] +fn manifest_descriptor_carries_ref_name_annotation() { + // The OCI standard tag annotation must be set on the manifest + // descriptor in `index.json` so external tools (`oras`, + // `crane manifest`, `skopeo inspect`) see the tag. + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "production-v3").unwrap(); + + let index: Value = + serde_json::from_slice(&std::fs::read(path.join("index.json")).unwrap()).unwrap(); + let manifest = &index["manifests"][0]; + assert_eq!( + manifest["annotations"]["org.opencontainers.image.ref.name"] + .as_str() + .unwrap(), + "production-v3" + ); +} + +// ============================================================================= +// Tag validation. +// ============================================================================= + +#[test] +fn empty_tag_rejected_on_save() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let err = snap.to_oci(dir.path().join("snap"), "").unwrap_err(); + assert!(format!("{err}").contains("tag")); +} + +#[test] +fn empty_tag_rejected_on_load() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("snap"); + snap.to_oci(&path, "latest").unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "")); + assert!(format!("{err}").contains("tag")); +} + +#[test] +fn tag_with_illegal_leading_char_rejected() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let err = snap + .to_oci(dir.path().join("snap"), ".dotleader") + .unwrap_err(); + assert!(format!("{err}").contains("tag")); + + let err = snap + .to_oci(dir.path().join("snap"), "-dashleader") + .unwrap_err(); + assert!(format!("{err}").contains("tag")); +} + +#[test] +fn tag_with_illegal_chars_rejected() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let err = snap + .to_oci(dir.path().join("snap"), "with/slash") + .unwrap_err(); + assert!(format!("{err}").contains("tag")); + + let err = snap + .to_oci(dir.path().join("snap"), "with space") + .unwrap_err(); + assert!(format!("{err}").contains("tag")); +} + +#[test] +fn long_tag_within_limit_accepted() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let tag: String = "a".repeat(128); + snap.to_oci(dir.path().join("snap"), &tag).unwrap(); + let _ = Snapshot::from_oci(dir.path().join("snap"), &tag).unwrap(); +} + +#[test] +fn over_long_tag_rejected() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let tag: String = "a".repeat(129); + let err = snap.to_oci(dir.path().join("snap"), &tag).unwrap_err(); + assert!(format!("{err}").contains("tag")); +} + +// ============================================================================= +// Append-side error paths for `to_oci`. +// ============================================================================= + +#[test] +fn to_oci_refuses_layout_with_malformed_oci_layout_marker() { + // Directory exists with a corrupt oci-layout marker. We must + // refuse to touch it, even though it superficially looks like + // an OCI layout. Same rule applies for missing version field. + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + std::fs::create_dir(&path).unwrap(); + std::fs::write(path.join("oci-layout"), b"{garbage").unwrap(); + + let err = snap.to_oci(&path, "latest").unwrap_err(); + assert!( + format!("{err}").contains("oci-layout"), + "expected oci-layout marker error, got: {err}" + ); + + // The corrupt marker must not have been replaced. + assert_eq!(std::fs::read(path.join("oci-layout")).unwrap(), b"{garbage"); +} + +#[test] +fn to_oci_refuses_layout_with_oci_layout_missing_version() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + std::fs::create_dir(&path).unwrap(); + std::fs::write(path.join("oci-layout"), b"{}").unwrap(); + + let err = snap.to_oci(&path, "latest").unwrap_err(); + assert!( + format!("{err}").contains("imageLayoutVersion"), + "expected missing-version error, got: {err}" + ); +} + +#[test] +fn to_oci_refuses_layout_with_unsupported_version() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + std::fs::create_dir(&path).unwrap(); + std::fs::write( + path.join("oci-layout"), + br#"{"imageLayoutVersion":"99.0.0"}"#, + ) + .unwrap(); + + let err = snap.to_oci(&path, "latest").unwrap_err(); + assert!( + format!("{err}").contains("image layout version"), + "expected unsupported-version error, got: {err}" + ); +} + +#[test] +fn to_oci_refuses_layout_with_missing_index_on_append() { + // Layout with a valid `oci-layout` marker but no `index.json` + // is malformed. `to_oci` must refuse to append and not write a + // new (single-tag) index that would mask the corruption. + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + std::fs::create_dir(&path).unwrap(); + std::fs::write( + path.join("oci-layout"), + br#"{"imageLayoutVersion":"1.0.0"}"#, + ) + .unwrap(); + + let err = snap.to_oci(&path, "latest").unwrap_err(); + assert!( + format!("{err}").contains("index.json"), + "expected index-related error, got: {err}" + ); +} + +#[test] +fn to_oci_refuses_layout_with_malformed_index_on_append() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + std::fs::create_dir(&path).unwrap(); + std::fs::write( + path.join("oci-layout"), + br#"{"imageLayoutVersion":"1.0.0"}"#, + ) + .unwrap(); + std::fs::write(path.join("index.json"), b"{not json").unwrap(); + + let err = snap.to_oci(&path, "latest").unwrap_err(); + assert!( + format!("{err}").contains("index.json"), + "expected index-parse error, got: {err}" + ); + // The malformed index must not have been overwritten. + assert_eq!( + std::fs::read(path.join("index.json")).unwrap(), + b"{not json" + ); +} + +#[test] +fn to_oci_does_not_rewrite_oci_layout_on_append() { + // The oci-layout marker is created once, on the fresh-write + // path. On append, the writer must leave it alone (no spurious + // mtime bump, no risk of clobbering a marker the user is + // happy with). + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "first").unwrap(); + + let marker_path = path.join("oci-layout"); + let mtime_before = std::fs::metadata(&marker_path).unwrap().modified().unwrap(); + // Bound the test so the second save is reliably distinguishable + // from the first if it did rewrite. Filesystems generally have + // millisecond-or-better mtime resolution. + std::thread::sleep(std::time::Duration::from_millis(50)); + + snap.to_oci(&path, "second").unwrap(); + let mtime_after = std::fs::metadata(&marker_path).unwrap().modified().unwrap(); + assert_eq!( + mtime_before, mtime_after, + "oci-layout marker must not be rewritten on append" + ); +} + +// ============================================================================= +// Save-shape invariants. Verify the on-disk JSON we hand to standard +// OCI tools matches what the spec prescribes. +// ============================================================================= + +#[test] +fn manifest_descriptor_uses_image_manifest_media_type() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + let index: Value = + serde_json::from_slice(&std::fs::read(path.join("index.json")).unwrap()).unwrap(); + assert_eq!( + index["manifests"][0]["mediaType"].as_str().unwrap(), + "application/vnd.oci.image.manifest.v1+json" + ); +} + +#[test] +fn manifest_uses_correct_config_and_layer_media_types() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + let manifest: Value = + serde_json::from_slice(&std::fs::read(manifest_path(&path)).unwrap()).unwrap(); + assert_eq!( + manifest["config"]["mediaType"].as_str().unwrap(), + "application/vnd.hyperlight.sandbox.config.v1+json" + ); + assert_eq!(manifest["layers"].as_array().unwrap().len(), 1); + assert_eq!( + manifest["layers"][0]["mediaType"].as_str().unwrap(), + "application/vnd.hyperlight.snapshot.v1" + ); +} + +#[test] +fn save_writes_oci_layout_marker() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + let marker: Value = + serde_json::from_slice(&std::fs::read(path.join("oci-layout")).unwrap()).unwrap(); + assert_eq!(marker["imageLayoutVersion"].as_str().unwrap(), "1.0.0"); +} + +// ============================================================================= +// Tag selection edge cases. +// ============================================================================= + +#[test] +fn tag_lookup_is_case_sensitive() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "MyTag").unwrap(); + + // Different case must NOT match. + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "mytag")); + assert_err_contains(err, "no manifest tagged"); + + // Exact case loads. + let _ = Snapshot::from_oci(&path, "MyTag").unwrap(); +} + +#[test] +fn ref_name_annotation_key_is_case_sensitive() { + // If the index uses a misspelled annotation key (e.g. + // `org.OpenContainers.image.ref.name`), the manifest is treated + // as untagged and from_oci must not load it under any name. + let (_dir, path) = save_for_mutation(); + rewrite_index(&path, |idx| { + let anns = idx["manifests"][0]["annotations"].as_object_mut().unwrap(); + let value = anns.remove("org.opencontainers.image.ref.name").unwrap(); + anns.insert("org.OpenContainers.image.ref.name".to_string(), value); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "no manifest tagged"); +} + +#[test] +fn tag_with_all_valid_special_chars_accepted() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + let tag = "v1.2.3-rc.1_build"; + snap.to_oci(&path, tag).unwrap(); + let _ = Snapshot::from_oci(&path, tag).unwrap(); +} + +#[test] +fn three_tags_in_one_layout_each_loads() { + let mut sbox = create_test_sandbox(); + sbox.call::("AddToStatic", 1i32).unwrap(); + let s_a = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 2i32).unwrap(); + let s_b = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 4i32).unwrap(); + let s_c = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + s_a.to_oci(&path, "a").unwrap(); + s_b.to_oci(&path, "b").unwrap(); + s_c.to_oci(&path, "c").unwrap(); + + for (tag, expected) in [("a", 1), ("b", 3), ("c", 7)] { + let loaded = Snapshot::from_oci(&path, tag).unwrap(); + let mut s = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None) + .unwrap(); + assert_eq!( + s.call::("GetStatic", ()).unwrap(), + expected, + "tag {tag}" + ); + } +} + +#[test] +fn other_descriptor_annotations_do_not_interfere() { + // A manifest descriptor with the standard ref.name annotation + // PLUS unrelated annotations (cosign signatures, build + // pipelines, etc.) must still resolve by tag. + let (_dir, path) = save_for_mutation(); + rewrite_index(&path, |idx| { + let anns = idx["manifests"][0]["annotations"].as_object_mut().unwrap(); + anns.insert( + "dev.sigstore.cosign/signature".to_string(), + Value::from("MEUCIQDfake"), + ); + anns.insert("io.example.build.id".to_string(), Value::from("12345")); + }); + let _ = Snapshot::from_oci(&path, "latest").unwrap(); +} + +// ============================================================================= +// Bad sha256 digest format on the inner descriptors (config and snapshot +// layer). The index-side equivalent is `bad_digest_format_rejected`. +// ============================================================================= + +#[test] +fn bad_config_descriptor_digest_format_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + m["config"]["digest"] = Value::from("md5:deadbeef"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{err}"); + assert!( + msg.contains("digest"), + "expected digest-format error, got: {msg}" + ); +} + +#[test] +fn bad_snapshot_layer_descriptor_digest_format_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + m["layers"][0]["digest"] = Value::from("sha256:tooshort"); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{err}"); + assert!( + msg.contains("digest"), + "expected digest-format error, got: {msg}" + ); +} + +// ============================================================================= +// Missing inner blobs. +// ============================================================================= + +#[test] +fn missing_config_blob_rejected() { + let (_dir, path) = save_for_mutation(); + let cfg_path = find_config_blob(&path); + std::fs::remove_file(&cfg_path).unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + let msg = format!("{err}"); + assert!( + msg.contains("open") || msg.contains("No such") || msg.contains("not found"), + "expected missing-config-blob error, got: {msg}" + ); +} + +// ============================================================================= +// Size-bound enforcement. +// ============================================================================= + +#[test] +fn manifest_blob_too_large_rejected() { + // The manifest reader bounds to 1 MiB. Replace the manifest + // with junk longer than that and confirm the bound trips + // before any parsing. + let (_dir, path) = save_for_mutation(); + let mp = manifest_path(&path); + let huge = vec![b'a'; (1024 * 1024 + 16) as usize]; + std::fs::write(&mp, &huge).unwrap(); + // Update descriptor size to match so we hit the bound check, + // not the size mismatch check. + rewrite_index(&path, |idx| { + idx["manifests"][0]["size"] = Value::from(huge.len() as u64); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&path, "latest")); + assert_err_contains(err, "exceeds maximum allowed"); +} + +#[test] +fn config_blob_too_large_rejected() { + let (_dir, path) = save_for_mutation(); + let cfg_path = find_config_blob(&path); + let huge = vec![b'a'; (1024 * 1024 + 16) as usize]; + std::fs::write(&cfg_path, &huge).unwrap(); + rewrite_manifest(&path, |m| { + m["config"]["size"] = Value::from(huge.len() as u64); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&path, "latest")); + assert_err_contains(err, "exceeds maximum allowed"); +} + +#[test] +fn memory_size_too_large_rejected() { + let (_dir, path) = save_for_mutation(); + rewrite_config(&path, |cfg| { + // 16 GiB exceeds MAX_MEMORY_SIZE. + cfg["memory_size"] = Value::from(16u64 * 1024 * 1024 * 1024); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci(&path, "latest")); + assert_err_contains(err, "memory_size"); +} + +#[test] +fn snapshot_descriptor_size_disagrees_with_file_rejected() { + // Snapshot descriptor claims a different size than the actual + // blob file. The loader must reject before mmap-ing. + let (_dir, path) = save_for_mutation(); + rewrite_manifest(&path, |m| { + let sz = m["layers"][0]["size"].as_u64().unwrap(); + m["layers"][0]["size"] = Value::from(sz + 1); + }); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&path, "latest")); + let msg = format!("{err}"); + assert!( + msg.contains("snapshot blob size"), + "expected snapshot-blob descriptor disagreement error, got: {msg}" + ); +} + +// ============================================================================= +// `from_oci_unchecked` shares the same non-digest validators with +// `from_oci`. The key safety claim of the unchecked path is that it +// is faster, NOT that it is more permissive about anything other +// than digest checks. Pin that contract down here. +// ============================================================================= + +#[test] +fn from_oci_unchecked_validates_tag_format() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&path, "bad/tag")); + assert_err_contains(err, "tag"); +} + +#[test] +fn from_oci_unchecked_rejects_unknown_tag() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&path, "nosuch")); + assert_err_contains(err, "no manifest tagged"); +} + +#[test] +fn from_oci_unchecked_rejects_path_not_directory() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("not-a-dir"); + std::fs::write(&file_path, b"hi").unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&file_path, "latest")); + assert_err_contains(err, "not a directory"); +} + +#[test] +fn from_oci_unchecked_rejects_missing_oci_layout_marker() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + std::fs::remove_file(path.join("oci-layout")).unwrap(); + let err = unwrap_err_snapshot(Snapshot::from_oci_unchecked(&path, "latest")); + assert_err_contains(err, "oci-layout"); +} + +// ============================================================================= +// Round-trip data fidelity. +// +// The serde shape tests already prove individual fields parse, but +// they don't prove that all the values that came out of the producer +// reach the loaded snapshot. These tests pin down full round-trip +// fidelity for fields that are not exercised by the +// "load-then-call-the-guest" round-trip tests above. +// ============================================================================= + +#[test] +fn round_trip_preserves_stack_top_gva() { + let mut sbox = create_test_sandbox(); + let snap = sbox.snapshot().unwrap(); + let original = snap.stack_top_gva(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + assert_eq!(loaded.stack_top_gva(), original); +} + +#[test] +fn round_trip_preserves_non_default_scratch_size() { + use crate::sandbox::SandboxConfiguration; + let mut cfg = SandboxConfiguration::default(); + let custom_scratch: usize = 256 * 1024; + cfg.set_scratch_size(custom_scratch); + let snap = Snapshot::from_env( + GuestBinary::FilePath(simple_guest_as_string().unwrap()), + cfg, + ) + .unwrap(); + let original = snap.layout().get_scratch_size(); + assert_eq!(original, custom_scratch); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + assert_eq!(loaded.layout().get_scratch_size(), custom_scratch); +} + +#[test] +fn pre_init_snapshot_writes_initialise_entrypoint_kind() { + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + let cfg: Value = + serde_json::from_slice(&std::fs::read(find_config_blob(&path)).unwrap()).unwrap(); + assert_eq!(cfg["entrypoint"]["kind"].as_str().unwrap(), "initialise"); + assert!( + cfg["entrypoint"].get("sregs").is_none(), + "Initialise snapshot must not carry sregs in the config" + ); +} + +#[test] +fn already_initialised_snapshot_writes_call_entrypoint_kind() { + let mut sbox = create_test_sandbox(); + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + let cfg: Value = + serde_json::from_slice(&std::fs::read(find_config_blob(&path)).unwrap()).unwrap(); + assert_eq!(cfg["entrypoint"]["kind"].as_str().unwrap(), "call"); + assert!( + cfg["entrypoint"]["sregs"].is_object(), + "Call snapshot must carry sregs in the config" + ); +} + +#[test] +fn round_trip_preserves_host_function_signatures() { + // Save a snapshot with a custom host function signature, load + // it, and confirm the recorded signatures survive. + let mut sbox = create_sandbox_with_custom_host_funcs(); + let snap = sbox.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + + let cfg: Value = + serde_json::from_slice(&std::fs::read(find_config_blob(&path)).unwrap()).unwrap(); + let funcs = cfg["host_functions"].as_array().unwrap(); + let add = funcs + .iter() + .find(|f| f["function_name"].as_str().unwrap() == "Add") + .expect("Add must be recorded"); + assert_eq!( + add["parameter_types"].as_array().unwrap().len(), + 2, + "Add signature must record two parameters" + ); + // Loading and using the snapshot must accept the same signature. + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let _ = MultiUseSandbox::from_snapshot(Arc::new(loaded), host_funcs_with_matching_add(), None) + .unwrap(); +} + +#[test] +fn snapshot_with_no_host_functions_round_trips() { + // A snapshot with `host_functions: []` must round-trip without + // confusing the loader (which has special handling for the + // empty-vs-None case). + let snap = create_snapshot_from_binary(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "latest").unwrap(); + + let cfg: Value = + serde_json::from_slice(&std::fs::read(find_config_blob(&path)).unwrap()).unwrap(); + assert!( + cfg["host_functions"].as_array().unwrap().is_empty(), + "expected empty host_functions array for pre-init snapshot" + ); + + // The default HostFunctions set is sufficient because the + // snapshot requires nothing. + let loaded = Snapshot::from_oci(&path, "latest").unwrap(); + let _ = + MultiUseSandbox::from_snapshot(Arc::new(loaded), HostFunctions::default(), None).unwrap(); +} + +// ============================================================================= +// Snapshot lineage and restore semantics. +// +// Hyperlight's snapshot model is NOT a tree. Each `MultiUseSandbox` +// has a process-local `sandbox_id`; `snapshot()` tags the snapshot +// with that id; `from_snapshot(snap)` adopts `snap.sandbox_id()` so +// the new sandbox can restore back to it; and `restore(snap)` +// requires `self.id == snap.sandbox_id()`. So sandboxes built from +// clones of the same `Arc` form a flat id-equivalence +// class within which restore is freely interchangeable. +// +// These tests pin down all the combinations of build-from-snapshot, +// take-more-snapshots, restore-out-of-order, and reject-across-class +// that follow from that model. +// ============================================================================= + +#[test] +fn linear_chain_restore_in_order() { + // Take three snapshots at different states in one sandbox, then + // restore to each in chronological order. After each restore, + // the static counter must read the value it had when that + // snapshot was taken. + let mut sbox = create_test_sandbox(); + let s0 = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 10i32).unwrap(); + let s10 = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 20i32).unwrap(); + let s30 = sbox.snapshot().unwrap(); + + sbox.restore(s0.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + sbox.restore(s10.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 10); + sbox.restore(s30.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 30); +} + +#[test] +fn linear_chain_restore_out_of_order() { + // Restore through the same chain but in a non-monotonic order + // (forward, back, forward, back). Snapshots within one + // id-equivalence class are NOT ordered by when they were + // taken: any can be restored to from any other. + let mut sbox = create_test_sandbox(); + let s0 = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 7i32).unwrap(); + let s7 = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 100i32).unwrap(); + let s107 = sbox.snapshot().unwrap(); + + let order = [&s107, &s0, &s7, &s107, &s0]; + let expected = [107, 0, 7, 107, 0]; + for (snap, want) in order.iter().zip(expected.iter()) { + sbox.restore((*snap).clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), *want); + } +} + +#[test] +fn restore_then_call_then_snapshot_then_restore() { + // Restore changes the live state, but it must NOT invalidate + // the snapshot that was just used. After restoring to S1, the + // sandbox can still take a new snapshot and restore back to + // either S1 or the new one. + let mut sbox = create_test_sandbox(); + let s_init = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 4i32).unwrap(); + + // Restore back to init. + sbox.restore(s_init.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + + // Mutate again, snapshot, mutate further. + sbox.call::("AddToStatic", 9i32).unwrap(); + let s_post_restore = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 100i32).unwrap(); + + // Restore to either reachable snapshot. + sbox.restore(s_post_restore.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 9); + sbox.restore(s_init.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); +} + +#[test] +fn restore_idempotent() { + // Restoring to the same snapshot twice in a row must produce + // the same observable state both times. + let mut sbox = create_test_sandbox(); + sbox.call::("AddToStatic", 11i32).unwrap(); + let s = sbox.snapshot().unwrap(); + + sbox.call::("AddToStatic", 22i32).unwrap(); + sbox.restore(s.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 11); + + // No mutation between restores. + sbox.restore(s.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 11); + + // Mutation after the second restore must take effect. + sbox.call::("AddToStatic", 1i32).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 12); +} + +#[test] +fn from_snapshot_then_snapshot_then_restore_to_both() { + // Build sandbox B from snapshot S0 (B inherits S0's id). + // B takes its own snapshot S1 (also tagged with S0's id). Both + // S0 and S1 must be reachable from B via `restore`. + // + // Note: only snapshots taken from a RUNNING sandbox (with + // sregs) are valid restore targets. We therefore start from a + // snapshot of a running sandbox, not a pre-init snapshot. + let mut seed = create_test_sandbox(); + let s0 = seed.snapshot().unwrap(); + + let mut b = MultiUseSandbox::from_snapshot(s0.clone(), HostFunctions::default(), None).unwrap(); + b.call::("AddToStatic", 5i32).unwrap(); + let s1 = b.snapshot().unwrap(); + b.call::("AddToStatic", 10i32).unwrap(); + + // Restore back to S1. + b.restore(s1.clone()).unwrap(); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 5); + + // Restore back further to the constructor snapshot S0. + b.restore(s0.clone()).unwrap(); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 0); +} + +#[test] +fn arc_clone_lineage_two_sandboxes_each_restores_to_either() { + // Two sandboxes built from the SAME Arc share the + // sandbox_id. Each takes its own snapshot. Each must be + // restorable to (a) its own derived snapshot, (b) the shared + // root snapshot, and (c) the OTHER sandbox's derived snapshot + // (because all four snapshots share one id). + // + // Note: the shared root must be a running-sandbox snapshot so + // that restore() can use its sregs. + let mut seed = create_test_sandbox(); + let snap_root = seed.snapshot().unwrap(); + + let mut a = + MultiUseSandbox::from_snapshot(snap_root.clone(), HostFunctions::default(), None).unwrap(); + let mut b = + MultiUseSandbox::from_snapshot(snap_root.clone(), HostFunctions::default(), None).unwrap(); + + a.call::("AddToStatic", 3i32).unwrap(); + let snap_a = a.snapshot().unwrap(); + + b.call::("AddToStatic", 70i32).unwrap(); + let snap_b = b.snapshot().unwrap(); + + // a: own snap then root then b's snap. + a.restore(snap_a.clone()).unwrap(); + assert_eq!(a.call::("GetStatic", ()).unwrap(), 3); + a.restore(snap_root.clone()).unwrap(); + assert_eq!(a.call::("GetStatic", ()).unwrap(), 0); + a.restore(snap_b.clone()).unwrap(); + assert_eq!(a.call::("GetStatic", ()).unwrap(), 70); + + // b: cross-restore the other way. + b.restore(snap_a.clone()).unwrap(); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 3); + b.restore(snap_root.clone()).unwrap(); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 0); + b.restore(snap_b.clone()).unwrap(); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 70); +} + +#[test] +fn separate_from_snapshot_calls_share_id_class_through_lineage() { + // Build sandbox A from a running-sandbox snapshot snap_root. + // A takes snap_a. Then build sandbox B from snap_a (a different + // Arc, but B adopts snap_a.sandbox_id == snap_root.sandbox_id). + // B must be restorable to BOTH snap_a and snap_root because + // they all share one id. + let mut seed = create_test_sandbox(); + let snap_root = seed.snapshot().unwrap(); + + let mut a = + MultiUseSandbox::from_snapshot(snap_root.clone(), HostFunctions::default(), None).unwrap(); + a.call::("AddToStatic", 5i32).unwrap(); + let snap_a = a.snapshot().unwrap(); + + let mut b = + MultiUseSandbox::from_snapshot(snap_a.clone(), HostFunctions::default(), None).unwrap(); + b.restore(snap_a.clone()).unwrap(); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 5); + b.restore(snap_root.clone()).unwrap(); + assert_eq!(b.call::("GetStatic", ()).unwrap(), 0); +} + +#[test] +fn separate_oci_loads_yield_independent_id_classes() { + // `Snapshot::from_oci` assigns a fresh id on every load. Two + // loads of the same OCI tag therefore produce snapshots with + // DIFFERENT ids. Sandboxes built from one cannot restore to a + // snapshot from the other, even though the underlying bytes + // are identical. + // + // Save a running-sandbox snapshot so the loaded snapshot is + // a valid restore target. + let mut seed = create_test_sandbox(); + let snap = seed.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "v1").unwrap(); + + let s_x = Arc::new(Snapshot::from_oci(&path, "v1").unwrap()); + let s_y = Arc::new(Snapshot::from_oci(&path, "v1").unwrap()); + + let mut sbox_x = + MultiUseSandbox::from_snapshot(s_x.clone(), HostFunctions::default(), None).unwrap(); + let err = sbox_x.restore(s_y.clone()).unwrap_err(); + assert!( + format!("{err}").to_lowercase().contains("snapshot") + || format!("{err}").to_lowercase().contains("mismatch"), + "expected sandbox/snapshot id-mismatch error, got: {err}" + ); + + // sbox_x can still restore to its own loaded snapshot. + sbox_x.restore(s_x.clone()).unwrap(); + assert_eq!(sbox_x.call::("GetStatic", ()).unwrap(), 0); +} + +#[test] +fn oci_loaded_snapshot_supports_full_lifecycle() { + // Full round-trip: save (from a running sandbox so the loaded + // snapshot is a valid restore target), load, build sandbox, + // mutate, snapshot, mutate, restore, mutate, snapshot, restore. + // Both pre- and post-load snapshots in the loaded id class must + // remain restore-compatible across an arbitrary number of + // cycles. + let mut seed = create_test_sandbox(); + let snap = seed.snapshot().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("layout"); + snap.to_oci(&path, "v1").unwrap(); + + let loaded = Arc::new(Snapshot::from_oci(&path, "v1").unwrap()); + let mut sbox = + MultiUseSandbox::from_snapshot(loaded.clone(), HostFunctions::default(), None).unwrap(); + + sbox.call::("AddToStatic", 1i32).unwrap(); + let s1 = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 2i32).unwrap(); + let s3 = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 4i32).unwrap(); + + sbox.restore(s1.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 1); + sbox.restore(s3.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 3); + sbox.restore(loaded.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + + // Take a fresh snapshot post-restore. It is in the same id + // class and remains interchangeable with the others. + let s_post = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 50i32).unwrap(); + sbox.restore(s_post.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + sbox.restore(s3.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 3); +} + +#[test] +fn restore_invariant_under_intermediate_mutations() { + // Restoring to S followed by an arbitrary number of + // mutate-then-restore cycles must always produce the same + // post-restore observable state. This is the core "snapshot + // and restore really mean what they say" property. + let mut sbox = create_test_sandbox(); + sbox.call::("AddToStatic", 13i32).unwrap(); + let s = sbox.snapshot().unwrap(); + + let mutations = [3, 5, 7, 11, 13, 17, 19]; + for m in mutations { + sbox.call::("AddToStatic", m).unwrap(); + sbox.restore(s.clone()).unwrap(); + assert_eq!( + sbox.call::("GetStatic", ()).unwrap(), + 13, + "restore must reset to the snapshotted value regardless of intermediate mutation {m}" + ); + } +} + +#[test] +fn many_arc_clones_one_snapshot_share_id() { + // Cloning Arc N times yields N references with + // identical sandbox_id. Each sandbox built from a clone shares + // the id and is mutually restore-compatible. Verifies that the + // id-equivalence-class semantics hold for arbitrary fan-out. + // + // The shared root must be a running-sandbox snapshot so the + // sandboxes can restore to it. + let mut seed = create_test_sandbox(); + let snap = seed.snapshot().unwrap(); + let mut sandboxes: Vec = (0..4) + .map(|_| { + MultiUseSandbox::from_snapshot(snap.clone(), HostFunctions::default(), None).unwrap() + }) + .collect(); + + // Each sandbox takes its own derived snapshot tagged with a + // unique value. + let mut snaps: Vec> = Vec::new(); + for (i, s) in sandboxes.iter_mut().enumerate() { + s.call::("AddToStatic", (i as i32 + 1) * 10).unwrap(); + snaps.push(s.snapshot().unwrap()); + } + + // Every sandbox can restore to every snapshot in the class. + for (i, sbox) in sandboxes.iter_mut().enumerate() { + for (j, target) in snaps.iter().enumerate() { + sbox.restore(target.clone()).unwrap(); + let want = (j as i32 + 1) * 10; + assert_eq!( + sbox.call::("GetStatic", ()).unwrap(), + want, + "sandbox {i} restored to snapshot {j} should observe value {want}" + ); + } + // And to the root snapshot. + sbox.restore(snap.clone()).unwrap(); + assert_eq!(sbox.call::("GetStatic", ()).unwrap(), 0); + } +} From 0937e54f8ffc31149312da2c6eb5494929b7b0e0 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:54:14 -0700 Subject: [PATCH 12/12] Add OCI snapshot benchmarks Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- src/hyperlight_host/benches/benchmarks.rs | 143 +++++++++++++++++++++- 1 file changed, 142 insertions(+), 1 deletion(-) diff --git a/src/hyperlight_host/benches/benchmarks.rs b/src/hyperlight_host/benches/benchmarks.rs index 462e8908d..0f9ca5b2a 100644 --- a/src/hyperlight_host/benches/benchmarks.rs +++ b/src/hyperlight_host/benches/benchmarks.rs @@ -153,6 +153,15 @@ fn sandbox_lifecycle_benchmark(c: &mut Criterion) { ); } + // Isolates the cost of building a MultiUseSandbox from an + // already-resident Snapshot. The Snapshot is loaded outside the + // timed region. + for size in SandboxSize::all() { + group.bench_function(format!("sandbox_from_snapshot/{}", size.name()), |b| { + bench_sandbox_from_snapshot(b, size) + }); + } + group.finish(); } @@ -347,6 +356,25 @@ fn bench_snapshot_restore(b: &mut criterion::Bencher, size: SandboxSize) { }); } +fn bench_sandbox_from_snapshot(b: &mut criterion::Bencher, size: SandboxSize) { + use hyperlight_host::HostFunctions; + use hyperlight_host::sandbox::snapshot::Snapshot; + + let dir = tempfile::tempdir().unwrap(); + let snap_path = dir.path().join("bench"); + { + let mut sbox = create_multiuse_sandbox_with_size(size); + let snapshot = sbox.snapshot().unwrap(); + snapshot.to_oci(&snap_path, "latest").unwrap(); + } + let loaded = std::sync::Arc::new(Snapshot::from_oci(&snap_path, "latest").unwrap()); + + b.iter(|| { + let _ = + MultiUseSandbox::from_snapshot(loaded.clone(), HostFunctions::default(), None).unwrap(); + }); +} + fn snapshots_benchmark(c: &mut Criterion) { let mut group = c.benchmark_group("snapshots"); @@ -551,6 +579,118 @@ fn shared_memory_benchmark(c: &mut Criterion) { group.finish(); } +// ============================================================================ +// Benchmark Category: Snapshot Files +// ============================================================================ + +fn snapshot_file_benchmark(c: &mut Criterion) { + use hyperlight_host::HostFunctions; + use hyperlight_host::sandbox::snapshot::Snapshot; + + let mut group = c.benchmark_group("snapshot_files"); + + // Pre-create OCI snapshot images for all sizes. + let dirs: Vec<_> = SandboxSize::all() + .iter() + .map(|size| { + let dir = tempfile::tempdir().unwrap(); + let snap_path = dir.path().join(size.name()); + let snapshot = { + let mut sbox = create_multiuse_sandbox_with_size(*size); + sbox.snapshot().unwrap() + }; + snapshot.to_oci(&snap_path, "latest").unwrap(); + (dir, snapshot, snap_path) + }) + .collect(); + + // Benchmark: save_snapshot. Wipe the layout between iterations + // so each save measures a fresh write rather than a tag-append. + for (i, size) in SandboxSize::all().iter().enumerate() { + let snap_dir = tempfile::tempdir().unwrap(); + let path = snap_dir.path().join("bench"); + let snapshot = &dirs[i].1; + group.bench_function(format!("save_snapshot/{}", size.name()), |b| { + b.iter_batched( + || { + let _ = std::fs::remove_dir_all(&path); + }, + |_| snapshot.to_oci(&path, "latest").unwrap(), + criterion::BatchSize::PerIteration, + ); + }); + } + + // Benchmark: load_snapshot (parse manifest + config + mmap blob). + for (i, size) in SandboxSize::all().iter().enumerate() { + let snap_path = dirs[i].2.clone(); + group.bench_function(format!("load_snapshot/{}", size.name()), |b| { + b.iter(|| { + let _ = Snapshot::from_oci(&snap_path, "latest").unwrap(); + }); + }); + } + + // Benchmark: load_snapshot_unchecked (skip blob digest verification). + for (i, size) in SandboxSize::all().iter().enumerate() { + let snap_path = dirs[i].2.clone(); + group.bench_function(format!("load_snapshot_unchecked/{}", size.name()), |b| { + b.iter(|| { + let _ = Snapshot::from_oci_unchecked(&snap_path, "latest").unwrap(); + }); + }); + } + + // Benchmark: cold_start_via_evolve (new + evolve + call) + for size in SandboxSize::all() { + group.bench_function(format!("cold_start_via_evolve/{}", size.name()), |b| { + b.iter(|| { + let mut sbox = create_multiuse_sandbox_with_size(size); + sbox.call::("Echo", "hello\n".to_string()).unwrap(); + }); + }); + } + + // Benchmark: cold_start_via_snapshot (load + from_snapshot + call) + for (i, size) in SandboxSize::all().iter().enumerate() { + let snap_path = dirs[i].2.clone(); + group.bench_function(format!("cold_start_via_snapshot/{}", size.name()), |b| { + b.iter(|| { + let loaded = Snapshot::from_oci(&snap_path, "latest").unwrap(); + let mut sbox = MultiUseSandbox::from_snapshot( + std::sync::Arc::new(loaded), + HostFunctions::default(), + None, + ) + .unwrap(); + sbox.call::("Echo", "hello\n".to_string()).unwrap(); + }); + }); + } + + // Benchmark: cold_start_via_snapshot_unchecked (load unchecked + from_snapshot + call) + for (i, size) in SandboxSize::all().iter().enumerate() { + let snap_path = dirs[i].2.clone(); + group.bench_function( + format!("cold_start_via_snapshot_unchecked/{}", size.name()), + |b| { + b.iter(|| { + let loaded = Snapshot::from_oci_unchecked(&snap_path, "latest").unwrap(); + let mut sbox = MultiUseSandbox::from_snapshot( + std::sync::Arc::new(loaded), + HostFunctions::default(), + None, + ) + .unwrap(); + sbox.call::("Echo", "hello\n".to_string()).unwrap(); + }); + }, + ); + } + + group.finish(); +} + criterion_group! { name = benches; config = Criterion::default(); @@ -561,6 +701,7 @@ criterion_group! { guest_call_benchmark_large_param, function_call_serialization_benchmark, sample_workloads_benchmark, - shared_memory_benchmark + shared_memory_benchmark, + snapshot_file_benchmark } criterion_main!(benches);