From c9d621ad6107ba061f2b3226af03c83d79e0cc70 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:13:05 -0700 Subject: [PATCH 1/3] Refactor SandboxMemoryLayout Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- src/hyperlight_common/src/mem.rs | 42 ++ .../src/hypervisor/hyperlight_vm/x86_64.rs | 2 +- src/hyperlight_host/src/mem/layout.rs | 396 +++++------------- src/hyperlight_host/src/mem/mgr.rs | 14 +- .../src/sandbox/uninitialized_evolve.rs | 2 +- 5 files changed, 161 insertions(+), 295 deletions(-) diff --git a/src/hyperlight_common/src/mem.rs b/src/hyperlight_common/src/mem.rs index fb850acc8..fea8413bd 100644 --- a/src/hyperlight_common/src/mem.rs +++ b/src/hyperlight_common/src/mem.rs @@ -28,6 +28,23 @@ pub struct GuestMemoryRegion { pub ptr: u64, } +impl GuestMemoryRegion { + /// Size of a serialized `GuestMemoryRegion` in bytes. + pub const SERIALIZED_SIZE: usize = core::mem::size_of::(); + + /// Write this region's fields in native-endian byte order to `buf`. + /// Returns `Ok(())` on success, or `Err` if `buf` is too small. + pub fn write_to(&self, buf: &mut [u8]) -> Result<(), &'static str> { + if buf.len() < Self::SERIALIZED_SIZE { + return Err("buffer too small for GuestMemoryRegion"); + } + let s = core::mem::size_of::(); + buf[..s].copy_from_slice(&self.size.to_ne_bytes()); + buf[s..s * 2].copy_from_slice(&self.ptr.to_ne_bytes()); + Ok(()) + } +} + /// Maximum length of a file mapping label (excluding null terminator). pub const FILE_MAPPING_LABEL_MAX_LEN: usize = 63; @@ -80,3 +97,28 @@ pub struct HyperlightPEB { #[cfg(feature = "nanvix-unstable")] pub file_mappings: GuestMemoryRegion, } + +impl HyperlightPEB { + /// Write the PEB fields in native-endian byte order to `buf`. + /// The buffer must be at least `size_of::()` bytes. + /// Returns `Err` if the buffer is too small. + pub fn write_to(&self, buf: &mut [u8]) -> Result<(), &'static str> { + if buf.len() < core::mem::size_of::() { + return Err("buffer too small for HyperlightPEB"); + } + let regions = [ + &self.input_stack, + &self.output_stack, + &self.init_data, + &self.guest_heap, + #[cfg(feature = "nanvix-unstable")] + &self.file_mappings, + ]; + let mut offset = 0; + for region in regions { + region.write_to(&mut buf[offset..])?; + offset += GuestMemoryRegion::SERIALIZED_SIZE; + } + Ok(()) + } +} 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 698ab49e5..fe2c7d2ae 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs @@ -1504,7 +1504,7 @@ mod tests { let (mut hshm, gshm) = mem_mgr.build().unwrap(); - let peb_address = gshm.layout.peb_address; + let peb_address = gshm.layout.peb_address(); let stack_top_gva = hyperlight_common::layout::MAX_GVA as u64 - hyperlight_common::layout::SCRATCH_TOP_EXN_STACK_OFFSET + 1; diff --git a/src/hyperlight_host/src/mem/layout.rs b/src/hyperlight_host/src/mem/layout.rs index b55189969..198cc01a4 100644 --- a/src/hyperlight_host/src/mem/layout.rs +++ b/src/hyperlight_host/src/mem/layout.rs @@ -60,8 +60,9 @@ limitations under the License. //! | Input Data | //! +-------------------------------------------+ (scratch size) -use std::fmt::Debug; -use std::mem::{offset_of, size_of}; +#[cfg(feature = "nanvix-unstable")] +use std::mem::offset_of; +use std::mem::size_of; use hyperlight_common::mem::{HyperlightPEB, PAGE_SIZE_USIZE}; use tracing::{Span, instrument}; @@ -213,98 +214,27 @@ impl ResolvedGpa { } } -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug)] pub(crate) struct SandboxMemoryLayout { - pub(super) sandbox_memory_config: SandboxConfiguration, + /// Input data buffer size (from SandboxConfiguration). + pub(crate) input_data_size: usize, + /// Output data buffer size (from SandboxConfiguration). + pub(crate) output_data_size: usize, /// The heap size of this sandbox. - pub(super) heap_size: usize, + pub(crate) heap_size: usize, + /// 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, - - /// The following fields are offsets to the actual PEB struct fields. - /// They are used when writing the PEB struct itself - peb_offset: usize, - peb_input_data_offset: usize, - peb_output_data_offset: usize, - peb_init_data_offset: usize, - peb_heap_data_offset: usize, - #[cfg(feature = "nanvix-unstable")] - peb_file_mappings_offset: usize, - - guest_heap_buffer_offset: usize, - init_data_offset: usize, - pt_size: Option, - - // other - pub(crate) peb_address: usize, - code_size: usize, - // The offset in the sandbox memory where the code starts - guest_code_offset: usize, + /// Permission flags for the init data region. #[cfg_attr(feature = "nanvix-unstable", allow(unused))] - pub(crate) init_data_permissions: Option, - - // The size of the scratch region in physical memory; note that - // this will appear under the top of physical memory. + init_data_permissions: Option, + /// The size of the scratch region in physical memory. scratch_size: usize, - // The size of the snapshot region in physical memory; note that - // this will appear somewhere near the base of physical memory. + /// The size of the snapshot region in physical memory. snapshot_size: usize, -} - -impl Debug for SandboxMemoryLayout { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut ff = f.debug_struct("SandboxMemoryLayout"); - ff.field( - "Total Memory Size", - &format_args!("{:#x}", self.get_memory_size().unwrap_or(0)), - ) - .field("Heap Size", &format_args!("{:#x}", self.heap_size)) - .field( - "Init Data Size", - &format_args!("{:#x}", self.init_data_size), - ) - .field("PEB Address", &format_args!("{:#x}", self.peb_address)) - .field("PEB Offset", &format_args!("{:#x}", self.peb_offset)) - .field("Code Size", &format_args!("{:#x}", self.code_size)) - .field( - "Input Data Offset", - &format_args!("{:#x}", self.peb_input_data_offset), - ) - .field( - "Output Data Offset", - &format_args!("{:#x}", self.peb_output_data_offset), - ) - .field( - "Init Data Offset", - &format_args!("{:#x}", self.peb_init_data_offset), - ) - .field( - "Guest Heap Offset", - &format_args!("{:#x}", self.peb_heap_data_offset), - ); - #[cfg(feature = "nanvix-unstable")] - ff.field( - "File Mappings Offset", - &format_args!("{:#x}", self.peb_file_mappings_offset), - ); - ff.field( - "Guest Heap Buffer Offset", - &format_args!("{:#x}", self.guest_heap_buffer_offset), - ) - .field( - "Init Data Offset", - &format_args!("{:#x}", self.init_data_offset), - ) - .field("PT Size", &format_args!("{:#x}", self.pt_size.unwrap_or(0))) - .field( - "Guest Code Offset", - &format_args!("{:#x}", self.guest_code_offset), - ) - .field( - "Scratch region size", - &format_args!("{:#x}", self.scratch_size), - ) - .finish() - } + /// The size of the page tables (None if not yet set). + pt_size: Option, } impl SandboxMemoryLayout { @@ -338,65 +268,19 @@ impl SandboxMemoryLayout { if scratch_size > Self::MAX_MEMORY_SIZE { return Err(MemoryRequestTooBig(scratch_size, Self::MAX_MEMORY_SIZE)); } - let min_scratch_size = hyperlight_common::layout::min_scratch_size( - cfg.get_input_data_size(), - cfg.get_output_data_size(), - ); + let input_data_size = cfg.get_input_data_size(); + let output_data_size = cfg.get_output_data_size(); + let min_scratch_size = + hyperlight_common::layout::min_scratch_size(input_data_size, output_data_size); if scratch_size < min_scratch_size { return Err(MemoryRequestTooSmall(scratch_size, min_scratch_size)); } - let guest_code_offset = 0; - // The following offsets are to the fields of the PEB struct itself! - let peb_offset = code_size.next_multiple_of(PAGE_SIZE_USIZE); - let peb_input_data_offset = peb_offset + offset_of!(HyperlightPEB, input_stack); - let peb_output_data_offset = peb_offset + offset_of!(HyperlightPEB, output_stack); - let peb_init_data_offset = peb_offset + offset_of!(HyperlightPEB, init_data); - let peb_heap_data_offset = peb_offset + offset_of!(HyperlightPEB, guest_heap); - #[cfg(feature = "nanvix-unstable")] - let peb_file_mappings_offset = peb_offset + offset_of!(HyperlightPEB, file_mappings); - - // The following offsets are the actual values that relate to memory layout, - // which are written to PEB struct - let peb_address = Self::BASE_ADDRESS + peb_offset; - // make sure heap buffer starts at 4K boundary. - // The FileMappingInfo array is stored immediately after the PEB struct. - // We statically reserve space for MAX_FILE_MAPPINGS entries so that - // the heap never overlaps the array, even when all slots are used. - // The host writes file mapping metadata here via write_file_mapping_entry; - // the guest only reads the entries. We don't know at layout time how - // many file mappings the host will register, so we reserve space for - // the maximum number. - // The heap starts at the next page boundary after this reserved area. - #[cfg(feature = "nanvix-unstable")] - let file_mappings_array_end = peb_offset - + size_of::() - + hyperlight_common::mem::MAX_FILE_MAPPINGS - * size_of::(); - #[cfg(feature = "nanvix-unstable")] - let guest_heap_buffer_offset = file_mappings_array_end.next_multiple_of(PAGE_SIZE_USIZE); - #[cfg(not(feature = "nanvix-unstable"))] - let guest_heap_buffer_offset = - (peb_offset + size_of::()).next_multiple_of(PAGE_SIZE_USIZE); - - // make sure init data starts at 4K boundary - let init_data_offset = - (guest_heap_buffer_offset + heap_size).next_multiple_of(PAGE_SIZE_USIZE); let mut ret = Self { - peb_offset, + input_data_size, + output_data_size, heap_size, - peb_input_data_offset, - peb_output_data_offset, - peb_init_data_offset, - peb_heap_data_offset, - #[cfg(feature = "nanvix-unstable")] - peb_file_mappings_offset, - sandbox_memory_config: cfg, code_size, - guest_heap_buffer_offset, - peb_address, - guest_code_offset, - init_data_offset, init_data_size, init_data_permissions, pt_size: None, @@ -407,68 +291,64 @@ impl SandboxMemoryLayout { Ok(ret) } - /// Get the offset in guest memory to the output data size - #[instrument(skip_all, parent = Span::current(), level= "Trace")] - pub(super) fn get_output_data_size_offset(&self) -> usize { - // The size field is the first field in the `OutputData` struct - self.peb_output_data_offset + /// Offset of the PEB struct within the snapshot region. + pub(crate) fn peb_offset(&self) -> usize { + self.code_size.next_multiple_of(PAGE_SIZE_USIZE) } - /// Get the offset in guest memory to the init data size - #[instrument(skip_all, parent = Span::current(), level= "Trace")] - pub(super) fn get_init_data_size_offset(&self) -> usize { - // The init data size is the first field in the `GuestMemoryRegion` struct - self.peb_init_data_offset + /// Offset of the PEB file_mappings field. + #[cfg(feature = "nanvix-unstable")] + fn peb_file_mappings_offset(&self) -> usize { + self.peb_offset() + offset_of!(HyperlightPEB, file_mappings) } - #[instrument(skip_all, parent = Span::current(), level= "Trace")] - pub(crate) fn get_scratch_size(&self) -> usize { - self.scratch_size + /// Guest physical address of the PEB. + pub(crate) fn peb_address(&self) -> usize { + Self::BASE_ADDRESS + self.peb_offset() } - /// Get the offset in guest memory to the output data pointer. - #[instrument(skip_all, parent = Span::current(), level= "Trace")] - fn get_output_data_pointer_offset(&self) -> usize { - // This field is immediately after the output data size field, - // which is a `u64`. - self.get_output_data_size_offset() + size_of::() + /// Offset of the guest heap buffer within the snapshot region. + pub(crate) fn guest_heap_buffer_offset(&self) -> usize { + #[cfg(feature = "nanvix-unstable")] + { + let file_mappings_array_end = self.peb_offset() + + size_of::() + + hyperlight_common::mem::MAX_FILE_MAPPINGS + * size_of::(); + file_mappings_array_end.next_multiple_of(PAGE_SIZE_USIZE) + } + #[cfg(not(feature = "nanvix-unstable"))] + { + (self.peb_offset() + size_of::()).next_multiple_of(PAGE_SIZE_USIZE) + } + } + + /// Offset of the init data section within the snapshot region. + pub(crate) fn init_data_offset(&self) -> usize { + (self.guest_heap_buffer_offset() + self.heap_size).next_multiple_of(PAGE_SIZE_USIZE) + } + + /// The code offset is always 0. + pub(crate) fn guest_code_offset(&self) -> usize { + 0 } - /// Get the offset in guest memory to the init data pointer. #[instrument(skip_all, parent = Span::current(), level= "Trace")] - pub(super) fn get_init_data_pointer_offset(&self) -> usize { - // The init data pointer is immediately after the init data size field, - // which is a `u64`. - self.get_init_data_size_offset() + size_of::() + pub(crate) fn get_scratch_size(&self) -> usize { + self.scratch_size } /// Get the guest virtual address of the start of output data. #[instrument(skip_all, parent = Span::current(), level= "Trace")] pub(crate) fn get_output_data_buffer_gva(&self) -> u64 { - hyperlight_common::layout::scratch_base_gva(self.scratch_size) - + self.sandbox_memory_config.get_input_data_size() as u64 + hyperlight_common::layout::scratch_base_gva(self.scratch_size) + self.input_data_size as u64 } /// Get the offset into the host scratch buffer of the start of /// the output data. #[instrument(skip_all, parent = Span::current(), level= "Trace")] pub(crate) fn get_output_data_buffer_scratch_host_offset(&self) -> usize { - self.sandbox_memory_config.get_input_data_size() - } - - /// Get the offset in guest memory to the input data size. - #[instrument(skip_all, parent = Span::current(), level= "Trace")] - pub(super) fn get_input_data_size_offset(&self) -> usize { - // The input data size is the first field in the input stack's `GuestMemoryRegion` struct - self.peb_input_data_offset - } - - /// Get the offset in guest memory to the input data pointer. - #[instrument(skip_all, parent = Span::current(), level= "Trace")] - fn get_input_data_pointer_offset(&self) -> usize { - // The input data pointer is immediately after the input - // data size field in the input data `GuestMemoryRegion` struct which is a `u64`. - self.get_input_data_size_offset() + size_of::() + self.input_data_size } /// Get the guest virtual address of the start of input data @@ -488,9 +368,8 @@ impl SandboxMemoryLayout { /// location where page tables will be eagerly copied on restore #[instrument(skip_all, parent = Span::current(), level= "Trace")] pub(crate) fn get_pt_base_scratch_offset(&self) -> usize { - (self.sandbox_memory_config.get_input_data_size() - + self.sandbox_memory_config.get_output_data_size()) - .next_multiple_of(hyperlight_common::vmem::PAGE_SIZE) + (self.input_data_size + self.output_data_size) + .next_multiple_of(hyperlight_common::vmem::PAGE_SIZE) } /// Get the base GPA to which the page tables will be eagerly @@ -508,17 +387,11 @@ impl SandboxMemoryLayout { self.get_pt_base_gpa() + self.pt_size.unwrap_or(0) as u64 } - /// Get the offset in guest memory to the heap size - #[instrument(skip_all, parent = Span::current(), level= "Trace")] - fn get_heap_size_offset(&self) -> usize { - self.peb_heap_data_offset - } - /// Get the offset in guest memory to the file_mappings count field /// (the `size` field of the `GuestMemoryRegion` in the PEB). #[cfg(feature = "nanvix-unstable")] pub(crate) fn get_file_mappings_size_offset(&self) -> usize { - self.peb_file_mappings_offset + self.peb_file_mappings_offset() } /// Get the offset in guest memory to the file_mappings pointer field. @@ -531,7 +404,7 @@ impl SandboxMemoryLayout { /// (immediately after the PEB struct, within the same page). #[cfg(feature = "nanvix-unstable")] pub(crate) fn get_file_mappings_array_offset(&self) -> usize { - self.peb_offset + size_of::() + self.peb_offset() + size_of::() } /// Get the guest address of the FileMappingInfo array. @@ -540,32 +413,24 @@ impl SandboxMemoryLayout { (Self::BASE_ADDRESS + self.get_file_mappings_array_offset()) as u64 } - /// Get the offset of the heap pointer in guest memory, - #[instrument(skip_all, parent = Span::current(), level= "Trace")] - fn get_heap_pointer_offset(&self) -> usize { - // The heap pointer is immediately after the - // heap size field in the guest heap's `GuestMemoryRegion` struct which is a `u64`. - self.get_heap_size_offset() + size_of::() - } - /// Get the total size of guest memory in `self`'s memory /// layout. #[instrument(skip_all, parent = Span::current(), level= "Trace")] fn get_unaligned_memory_size(&self) -> usize { - self.init_data_offset + self.init_data_size + self.init_data_offset() + self.init_data_size } /// get the code offset /// This is the offset in the sandbox memory where the code starts #[instrument(skip_all, parent = Span::current(), level= "Trace")] pub(crate) fn get_guest_code_offset(&self) -> usize { - self.guest_code_offset + self.guest_code_offset() } /// Get the guest address of the code section in the sandbox #[instrument(skip_all, parent = Span::current(), level= "Trace")] pub(crate) fn get_guest_code_address(&self) -> usize { - Self::BASE_ADDRESS + self.guest_code_offset + Self::BASE_ADDRESS + self.guest_code_offset() } /// Get the total size of guest memory in `self`'s memory @@ -593,8 +458,8 @@ impl SandboxMemoryLayout { #[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( - self.sandbox_memory_config.get_input_data_size(), - self.sandbox_memory_config.get_output_data_size(), + self.input_data_size, + self.output_data_size, ); let min_scratch = min_fixed_scratch + size; if self.scratch_size < min_scratch { @@ -633,7 +498,7 @@ impl SandboxMemoryLayout { Code, ); - let expected_peb_offset = TryInto::::try_into(self.peb_offset)?; + let expected_peb_offset = TryInto::::try_into(self.peb_offset())?; if peb_offset != expected_peb_offset { return Err(new_error!( @@ -659,7 +524,7 @@ impl SandboxMemoryLayout { let heap_offset = builder.push_page_aligned(size_of::(), MemoryRegionFlags::READ, Peb); - let expected_heap_offset = TryInto::::try_into(self.guest_heap_buffer_offset)?; + let expected_heap_offset = TryInto::::try_into(self.guest_heap_buffer_offset())?; if heap_offset != expected_heap_offset { return Err(new_error!( @@ -683,7 +548,7 @@ impl SandboxMemoryLayout { Heap, ); - let expected_init_data_offset = TryInto::::try_into(self.init_data_offset)?; + let expected_init_data_offset = TryInto::::try_into(self.init_data_offset())?; if init_data_offset != expected_init_data_offset { return Err(new_error!( @@ -720,7 +585,7 @@ impl SandboxMemoryLayout { #[instrument(err(Debug), skip_all, parent = Span::current(), level= "Trace")] pub(crate) fn write_init_data(&self, out: &mut [u8], bytes: &[u8]) -> Result<()> { - out[self.init_data_offset..self.init_data_offset + self.init_data_size] + out[self.init_data_offset()..self.init_data_offset() + self.init_data_size] .copy_from_slice(bytes); Ok(()) } @@ -732,84 +597,43 @@ impl SandboxMemoryLayout { /// from this function. #[instrument(err(Debug), skip_all, parent = Span::current(), level= "Trace")] pub(crate) fn write_peb(&self, mem: &mut [u8]) -> Result<()> { - let guest_offset = SandboxMemoryLayout::BASE_ADDRESS; - - fn write_u64(mem: &mut [u8], offset: usize, value: u64) -> Result<()> { - if offset + 8 > mem.len() { - return Err(new_error!( - "Cannot write to offset {} in slice of len {}", - offset, - mem.len() - )); - } - mem[offset..offset + 8].copy_from_slice(&u64::to_ne_bytes(value)); - Ok(()) - } + use hyperlight_common::mem::GuestMemoryRegion; - macro_rules! get_address { - ($something:ident) => { - u64::try_from(guest_offset + self.$something)? - }; - } + let guest_base = Self::BASE_ADDRESS as u64; - // Start of setting up the PEB. The following are in the order of the PEB fields - - // Set up input buffer pointer - write_u64( - mem, - self.get_input_data_size_offset(), - self.sandbox_memory_config - .get_input_data_size() - .try_into()?, - )?; - write_u64( - mem, - self.get_input_data_pointer_offset(), - self.get_input_data_buffer_gva(), - )?; - - // Set up output buffer pointer - write_u64( - mem, - self.get_output_data_size_offset(), - self.sandbox_memory_config - .get_output_data_size() - .try_into()?, - )?; - write_u64( - mem, - self.get_output_data_pointer_offset(), - self.get_output_data_buffer_gva(), - )?; - - // Set up init data pointer - write_u64( - mem, - self.get_init_data_size_offset(), - (self.get_unaligned_memory_size() - self.init_data_offset).try_into()?, - )?; - let addr = get_address!(init_data_offset); - write_u64(mem, self.get_init_data_pointer_offset(), addr)?; - - // Set up heap buffer pointer - let addr = get_address!(guest_heap_buffer_offset); - write_u64(mem, self.get_heap_size_offset(), self.heap_size.try_into()?)?; - write_u64(mem, self.get_heap_pointer_offset(), addr)?; - - // Set up the file_mappings descriptor in the PEB. - // - The `size` field holds the number of valid FileMappingInfo - // entries currently written (initially 0 — entries are added - // later by map_file_cow / evolve). - // - The `ptr` field holds the guest address of the preallocated - // FileMappingInfo array - #[cfg(feature = "nanvix-unstable")] - write_u64(mem, self.get_file_mappings_size_offset(), 0)?; - #[cfg(feature = "nanvix-unstable")] - write_u64( - mem, - self.get_file_mappings_pointer_offset(), - self.get_file_mappings_array_gva(), - )?; + let peb = HyperlightPEB { + input_stack: GuestMemoryRegion { + size: self.input_data_size as u64, + ptr: self.get_input_data_buffer_gva(), + }, + output_stack: GuestMemoryRegion { + size: self.output_data_size as u64, + ptr: self.get_output_data_buffer_gva(), + }, + init_data: GuestMemoryRegion { + size: (self.get_unaligned_memory_size() - self.init_data_offset()) as u64, + ptr: guest_base + self.init_data_offset() as u64, + }, + guest_heap: GuestMemoryRegion { + size: self.heap_size as u64, + ptr: guest_base + self.guest_heap_buffer_offset() as u64, + }, + // Set up the file_mappings descriptor in the PEB. + // - The `size` field holds the number of valid FileMappingInfo + // entries currently written (initially 0 — entries are added + // later by map_file_cow / evolve). + // - The `ptr` field holds the guest address of the preallocated + // FileMappingInfo array + #[cfg(feature = "nanvix-unstable")] + file_mappings: GuestMemoryRegion { + size: 0, // entry count, populated later by map_file_cow + ptr: self.get_file_mappings_array_gva(), + }, + }; + + let offset = self.peb_offset(); + peb.write_to(&mut mem[offset..offset + size_of::()]) + .map_err(|e| new_error!("failed to write PEB: {}", e))?; // End of setting up the PEB diff --git a/src/hyperlight_host/src/mem/mgr.rs b/src/hyperlight_host/src/mem/mgr.rs index 98c70734b..0ae593914 100644 --- a/src/hyperlight_host/src/mem/mgr.rs +++ b/src/hyperlight_host/src/mem/mgr.rs @@ -405,7 +405,7 @@ impl SandboxMemoryManager { pub(crate) fn get_host_function_call(&mut self) -> Result { self.scratch_mem.try_pop_buffer_into::( self.layout.get_output_data_buffer_scratch_host_offset(), - self.layout.sandbox_memory_config.get_output_data_size(), + self.layout.output_data_size, ) } @@ -420,7 +420,7 @@ impl SandboxMemoryManager { self.scratch_mem.push_buffer( self.layout.get_input_data_buffer_scratch_host_offset(), - self.layout.sandbox_memory_config.get_input_data_size(), + self.layout.input_data_size, data, ) } @@ -437,7 +437,7 @@ impl SandboxMemoryManager { self.scratch_mem.push_buffer( self.layout.get_input_data_buffer_scratch_host_offset(), - self.layout.sandbox_memory_config.get_input_data_size(), + self.layout.input_data_size, buffer, )?; Ok(()) @@ -449,7 +449,7 @@ impl SandboxMemoryManager { pub(crate) fn get_guest_function_call_result(&mut self) -> Result { self.scratch_mem.try_pop_buffer_into::( self.layout.get_output_data_buffer_scratch_host_offset(), - self.layout.sandbox_memory_config.get_output_data_size(), + self.layout.output_data_size, ) } @@ -458,7 +458,7 @@ impl SandboxMemoryManager { pub(crate) fn read_guest_log_data(&mut self) -> Result { self.scratch_mem.try_pop_buffer_into::( self.layout.get_output_data_buffer_scratch_host_offset(), - self.layout.sandbox_memory_config.get_output_data_size(), + self.layout.output_data_size, ) } @@ -467,7 +467,7 @@ impl SandboxMemoryManager { loop { let Ok(_) = self.scratch_mem.try_pop_buffer_into::>( self.layout.get_output_data_buffer_scratch_host_offset(), - self.layout.sandbox_memory_config.get_output_data_size(), + self.layout.output_data_size, ) else { break; }; @@ -476,7 +476,7 @@ impl SandboxMemoryManager { loop { let Ok(_) = self.scratch_mem.try_pop_buffer_into::>( self.layout.get_input_data_buffer_scratch_host_offset(), - self.layout.sandbox_memory_config.get_input_data_size(), + self.layout.input_data_size, ) else { break; }; diff --git a/src/hyperlight_host/src/sandbox/uninitialized_evolve.rs b/src/hyperlight_host/src/sandbox/uninitialized_evolve.rs index 428594d37..20cf24f9c 100644 --- a/src/hyperlight_host/src/sandbox/uninitialized_evolve.rs +++ b/src/hyperlight_host/src/sandbox/uninitialized_evolve.rs @@ -73,7 +73,7 @@ pub(super) fn evolve_impl_multi_use(u_sbox: UninitializedSandbox) -> Result() }; let peb_addr = { - let peb_u64 = u64::try_from(hshm.layout.peb_address)?; + let peb_u64 = u64::try_from(hshm.layout.peb_address())?; RawPtr::from(peb_u64) }; From 35dae5be0b6786e4d08d024779841ec9bd9d76dd Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:05:44 -0700 Subject: [PATCH 2/3] Add snapshot file format design doc Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- docs/snapshot-file-implementation-plan.md | 322 ++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 docs/snapshot-file-implementation-plan.md diff --git a/docs/snapshot-file-implementation-plan.md b/docs/snapshot-file-implementation-plan.md new file mode 100644 index 000000000..d8668f2b2 --- /dev/null +++ b/docs/snapshot-file-implementation-plan.md @@ -0,0 +1,322 @@ +# Snapshot File Format + +## Overview + +Save a `Snapshot` to disk and load it back via zero-copy file +mapping, so that a `MultiUseSandbox` can be created directly from a +file without re-parsing the guest ELF or re-running guest init code. + +- **Linux**: `mmap(MAP_PRIVATE)` at page-aligned offset - zero copy, + demand-paged by the kernel. +- **Windows**: `CreateFileMappingA(PAGE_READONLY)` + + `MapViewOfFile(FILE_MAP_READ)` - zero copy, demand-paged by the OS. + +Cross-platform (Linux + Windows). Default feature flags only +(`nanvix-unstable`, `crashdump`, `gdb` not handled). + +--- + +## File Format + +The file uses a versioned header with two independent version checks: + +- **Format version** (`FormatVersion` enum): controls the byte layout + of the header itself. A format version mismatch may be convertible + by re-serializing the header. +- **ABI version** (`SNAPSHOT_ABI_VERSION` constant): covers the + contents and interpretation of the memory blob. An ABI mismatch + means the snapshot must be regenerated from the guest binary. + +``` +Offset Size Field +------ ------- -------------------------------------------------- +0 4 Magic bytes: "HLS\0" +4 4 Format version (u32 LE: 1 = V1) +8 4 Architecture tag (u32 LE: 1 = x86_64, 2 = aarch64) +12 4 ABI version (u32 LE: must match SNAPSHOT_ABI_VERSION) +16 32 Content hash (blake3, over memory blob only) +48 8 stack_top_gva (u64 LE) +56 8 Entrypoint tag (u64 LE: 0 = Initialise, 1 = Call) +64 8 Entrypoint address (u64 LE) +72 8 input_data_size (u64 LE) +80 8 output_data_size (u64 LE) +88 8 heap_size (u64 LE) +96 8 code_size (u64 LE) +104 8 init_data_size (u64 LE) +112 8 init_data_permissions (u64 LE: 0 = None, else bits) +120 8 scratch_size (u64 LE) +128 8 snapshot_size (u64 LE) +136 8 pt_size (u64 LE: 0 = None) +144 8 memory_size (u64 LE) - byte length of memory blob + Derivable from layout fields today, but stored for + forward compat (e.g. compression). +152 8 memory_offset (u64 LE) - byte offset from file start + Always SNAPSHOT_HEADER_SIZE today, but stored so a + future format can relocate the blob without breaking. +160 8 has_sregs (u64 LE: 1 = present, 0 = absent) +168 8 hypervisor_tag (u64 LE: 1 = KVM, 2 = MSHV, 3 = WHP) +176 952 sregs fields (all widened to u64 LE, see below) +1120 2976 Zero padding to 4096-byte boundary +4096 * Memory blob (page-aligned, uncompressed, mmap target) +*+4096 4096 Trailing zero padding (guard page backing for Windows) +``` + +Total header before padding: 1128 bytes, well within the 4096-byte +page. + +The trailing PAGE_SIZE padding exists because Windows read-only file +mappings cannot extend beyond the file's actual size. +`ReadonlySharedMemory::from_file_windows` maps the entire file and +uses `VirtualProtect(PAGE_NOACCESS)` on both the first page (header) +and last page (trailing padding) as guard pages. Linux ignores this +padding - its guard pages come from an anonymous mmap reservation. + +### Layout fields + +The 9 layout fields (offsets 72-136) are the primary inputs to +`SandboxMemoryLayout::new()`. On load, a `SandboxConfiguration` is +reconstructed from `input_data_size`, `output_data_size`, `heap_size`, +and `scratch_size`; the remaining fields (`code_size`, +`init_data_size`, `init_data_permissions`) are passed directly. +`snapshot_size` and `pt_size` are set after construction. + +### Hypervisor tag + +Segment register hidden-cache fields (`unusable`, `type_`, +`granularity`, `db`) differ between KVM, MSHV, and WHP for the same +architectural state. Restoring sregs captured on one hypervisor into +another may be rejected or produce subtly wrong behavior. The +`hypervisor_tag` field ensures snapshots are only loaded on the same +hypervisor that created them. See "Cross-hypervisor snapshot +portability" under Future Work for how this restriction could be +relaxed. + +### Special registers (sregs) + +The vCPU special registers are persisted because the guest init +code sets up a GDT, IDT, TSS, and segment descriptors that differ +from `standard_64bit_defaults`. Without the captured sregs, the guest +triple-faults on dispatch. Specifically, the guest init sets: + +- cs/ds/es/fs/gs/ss with proper selectors, limits, and granularity +- GDT and IDT base/limit pointing into guest high memory +- TSS (task register) with a valid base, selector, and limit +- LDT marked as unusable + +All fields widened to u64 LE: 8 segment regs x 13 fields + 2 table +regs x 2 fields + 7 control regs + 4 interrupt bitmap = 119 u64s +(952 bytes). Always written; ignored on load when `has_sregs = 0`. + +### What is NOT persisted + +| Field | Reason | +|---|---| +| `sandbox_id` | Process-local counter; fresh ID assigned on load | +| `LoadInfo` | Debug-only; reconstructible from ELF if needed | +| `regions` | Always empty after snapshot (absorbed into memory) | +| Runtime config | Defaults used at load time | +| Host function defs | Deferred to a follow-up PR | + +### What IS persisted + +The memory blob contains **only the snapshot region**: guest code, +PEB, heap, init data, and page tables (`ReadonlySharedMemory`). + +The **scratch region** is recreated fresh on load via +`ExclusiveSharedMemory::new()`, then initialized by +`update_scratch_bookkeeping()` (copies page tables from snapshot to +scratch, writes I/O buffer metadata). + +--- + +## Saving and Loading + +### `Snapshot::to_file(&self, path)` / `Snapshot::from_file(path)` + +Manual binary serialization via `SnapshotPreamble` + `SnapshotHeaderV1` +structs with `write_to` / `read_from` methods, followed by the raw +memory blob and trailing padding. `from_file` maps the memory blob +via `ReadonlySharedMemory::from_file(&file, offset, len)`. +`from_file_unchecked` skips the blake3 hash verification for trusted +environments. + +On load, the header is validated in order: magic, format version, +architecture, ABI version, hypervisor tag. Any mismatch produces a +descriptive error. + +### `ReadonlySharedMemory::from_file(file, offset, len)` + +Cross-platform entry point that dispatches to platform-specific +implementations: + +- **Linux** (`from_file_linux`): Allocates anonymous `PROT_NONE` + region (with guard pages), then `MAP_FIXED` the file content over + the usable portion with `PROT_READ | PROT_WRITE` + `MAP_PRIVATE`. + KVM/MSHV need writable host mappings for CoW page fault handling. + `HostMapping::Drop` calls `munmap` on the full region. + +- **Windows** (`from_file_windows`): `CreateFileMappingA(PAGE_READONLY)` + + `MapViewOfFile(FILE_MAP_READ)` covering the full file (header + + blob + trailing padding). The header becomes the leading guard page + and the trailing padding becomes the trailing guard page, both via + `VirtualProtect(PAGE_NOACCESS)`. The `HostMapping` carries the file + mapping handle for the surrogate process. `HostMapping::Drop` calls + `UnmapViewOfFile` + `CloseHandle`. + +Both paths produce a `HostMapping` with the standard layout: +`ptr` = start of first guard page, `size` = guard + usable + guard. +`base_ptr() = ptr + PAGE_SIZE`, `mem_size() = size - 2*PAGE_SIZE`. + +### `MultiUseSandbox::from_snapshot(snapshot: Arc)` + +Creates a sandbox bypassing `UninitializedSandbox` and `evolve()`: + +1. Create default `FunctionRegistry` +2. Build `SandboxConfiguration` from snapshot layout fields +3. `SandboxMemoryManager::from_snapshot()` - clones the + `ReadonlySharedMemory`, creates fresh scratch +4. `mgr.build()` - splits into host/guest views, runs + `update_scratch_bookkeeping()` +5. `setup_signal_handlers()` (Linux only - VCPU interrupt signaling) +6. `set_up_hypervisor_partition()` - creates VM (KVM/MSHV on Linux, + WHP on Windows), maps slot 0 (snapshot) and slot 1 (scratch) +7. `vm.initialise()` - runs guest init if `NextAction::Initialise`, + no-op if `NextAction::Call` +8. For post-init snapshots, `vm.apply_sregs()` applies captured + sregs (sets sregs + pending TLB flush, no redundant GPR/debug/FPU + resets) +9. Returns `MultiUseSandbox` + +Host functions are not yet supported when loading from snapshot. +A `SnapshotLoader` builder with `.with_host_function()` is planned +as future work. + +### Supporting changes + +- `SandboxMemoryLayout` simplified to 9 `pub(crate)` fields with + computed `#[inline]` offset methods; `new()` takes + `SandboxConfiguration`, `code_size`, `init_data_size`, + `init_data_permissions` +- `HyperlightPEB::write_to()` and `GuestMemoryRegion::write_to()` + added to `hyperlight_common` +- `HyperlightVm::apply_sregs()` added to `hyperlight_vm/x86_64.rs` + for efficient sreg restore without redundant register resets + +--- + +## Files + +| File | Purpose | +|---|---| +| `src/hyperlight_host/src/sandbox/snapshot.rs` | File format types, `to_file`, `from_file`, `from_file_unchecked`, sregs serialization, `HypervisorTag`, 10 tests | +| `src/hyperlight_host/src/sandbox/initialized_multi_use.rs` | `MultiUseSandbox::from_snapshot(Arc)` (cross-platform) | +| `src/hyperlight_host/src/mem/shared_mem.rs` | `ReadonlySharedMemory::from_file()` (cross-platform dispatch to `from_file_linux` / `from_file_windows`) | +| `src/hyperlight_host/src/mem/memory_region.rs` | `SurrogateMapping` routing for `Snapshot` regions | +| `src/hyperlight_host/src/mem/layout.rs` | Simplified to 9 fields, computed offset methods, `write_peb()` uses `HyperlightPEB::write_to()` | +| `src/hyperlight_common/src/mem.rs` | `HyperlightPEB::write_to()`, `GuestMemoryRegion::write_to()` | +| `src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs` | `apply_sregs()` method | +| `src/hyperlight_host/benches/benchmarks.rs` | `snapshot_files` benchmark group | + +--- + +## Tests + +All in `snapshot_file_tests` module inside `snapshot.rs`: + +1. `from_snapshot_in_memory` - pre-init snapshot (Initialise entrypoint) +2. `from_snapshot_post_init_in_memory` - post-init snapshot (Call + entrypoint) +3. `round_trip_save_load_call` - save post-init snapshot, load from + file, create sandbox, call guest function +4. `hash_verification_detects_corruption` - corrupt memory blob byte, + verify load fails +5. `arch_mismatch_rejected` - modify arch tag, verify load fails +6. `format_version_mismatch_rejected` - modify version, verify load + fails with "convertible" hint +7. `abi_version_mismatch_rejected` - modify ABI version, verify load + fails with "regenerated" hint +8. `restore_from_loaded_snapshot` - load, mutate, snapshot, mutate, + restore, verify +9. `multiple_sandboxes_from_same_file` - two sandboxes from same file, + verify independence +10. `snapshot_then_save_round_trip` - load, mutate, save, load again, + verify mutated state persisted + +--- + +## Benchmarks + +Benchmark group `snapshot_files` with 5 benchmarks per size (default, +small/8MB, medium/64MB, large/256MB): + +- `save_snapshot` - `snapshot.to_file()` +- `load_snapshot` - `Snapshot::from_file()` (mmap + hash verify) +- `cold_start_via_evolve` - `new()` + `evolve()` + `call("Echo")` +- `cold_start_via_snapshot` - `from_file()` + `from_snapshot()` + + `call("Echo")` +- `cold_start_via_snapshot_unchecked` - same with `from_file_unchecked()` + +--- + +## Results (Linux/KVM) + +All three paths measure end-to-end wall-clock time from zero state to +a completed guest function call (`Echo("hello\n") -> "hello\n"`). +Each path includes creating the VM, mapping memory, and dispatching +one guest call. + +- **evolve path**: parse ELF, build page tables, create VM, run guest + init code, call guest function +- **snapshot path (verified)**: open file, read header, mmap memory + blob from file at page-aligned offset, hash-verify entire blob, + create VM from snapshot, call guest function +- **snapshot path (unverified)**: same but skip hash verification + +| Heap size | evolve path | snapshot (verified) | snapshot (unverified) | Speedup (unverified vs evolve) | +|---|---|---|---|---| +| 128 KB (default) | 3.09 ms | 2.32 ms | 2.24 ms | 1.4x | +| 8 MB | 7.29 ms | 4.91 ms | 2.39 ms | 3.1x | +| 64 MB | 24.1 ms | 22.3 ms | 2.74 ms | 8.8x | +| 256 MB | 78.9 ms | 57.3 ms | 2.64 ms | 30x | + +The unverified snapshot path is constant time (~3 ms) regardless of +snapshot size because the mmap is lazy - pages are only faulted in as +the guest touches them. Hash verification dominates for larger +snapshots since it touches the entire memory blob. + +--- + +## Future Work + +- **`SnapshotLoader` builder**: Replace `from_snapshot(snapshot)` + with a builder that takes `.with_host_function()`, + `.with_interrupt_retry_delay()`, validates host functions at + `build()`. +- **Host function defs in file format**: Serialize function signatures + into the snapshot file, validate on load +- **Typed error variants**: `SnapshotVersionMismatch`, etc. +- **Feature-gate support**: `nanvix-unstable`, `crashdump`, `gdb` cfgs +- **Single-mmap loading**: mmap the entire snapshot file once and parse + the header from the mapped bytes instead of `read()` + separate mmap. + Requires refactoring `HostMapping` guard page assumptions. Saves ~1 us + per load (negligible vs ~3 ms total), but simplifies the I/O path. +- **Fuzz target**: Fuzz `from_file` with arbitrary bytes +- **CLI tool**: `hl snap bake?` +- **CoW overlay layers** +- **Cross-hypervisor snapshot portability**: The `hypervisor_tag` + rejects cross-hypervisor loads because segment register hidden-cache + fields differ between KVM, MSHV, and WHP. Could potentially be + relaxed in the future (needs sregs normalization and maybe more). +- **Huge page support**: The 4 KB header is sufficient for transparent + huge pages via `madvise(MADV_HUGEPAGE)`. Explicit `MAP_HUGETLB` + would require a 2 MB-aligned blob offset; the `memory_offset` field + already supports this without a format version bump. +- **OCI distribution** +- **Malicious header hardening**: The header is currently trusted after + magic/version/arch/ABI/hypervisor validation. A crafted snapshot + file could supply out-of-range layout fields (e.g. huge heap_size, + memory_size larger than the file, overlapping regions) that cause + excessive allocation, out-of-bounds access, or other misbehavior. + The blake3 hash covers the memory blob but not the header itself. + Consider: validating header fields against sane bounds, hashing the + full header, and fuzzing `from_file` with arbitrary bytes. From 94e728e0643a40e8b568224f9466109697ea4a31 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:07:46 -0700 Subject: [PATCH 3/3] Snapshot persistence to disk Save snapshots to disk via Snapshot::to_file(), load them back via Snapshot::from_file(). Create sandboxes directly from loaded snapshots via MultiUseSandbox::from_snapshot(), bypassing ELF parsing and guest init. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- src/hyperlight_host/benches/benchmarks.rs | 90 +- .../src/hypervisor/hyperlight_vm/x86_64.rs | 9 + src/hyperlight_host/src/mem/layout.rs | 16 +- src/hyperlight_host/src/mem/memory_region.rs | 4 +- src/hyperlight_host/src/mem/shared_mem.rs | 215 ++++ src/hyperlight_host/src/sandbox/host_funcs.rs | 19 +- .../src/sandbox/initialized_multi_use.rs | 136 +++ src/hyperlight_host/src/sandbox/snapshot.rs | 1078 +++++++++++++++++ .../src/sandbox/uninitialized.rs | 9 +- 9 files changed, 1556 insertions(+), 20 deletions(-) diff --git a/src/hyperlight_host/benches/benchmarks.rs b/src/hyperlight_host/benches/benchmarks.rs index c7cbc2631..9a23350c7 100644 --- a/src/hyperlight_host/benches/benchmarks.rs +++ b/src/hyperlight_host/benches/benchmarks.rs @@ -556,6 +556,93 @@ fn shared_memory_benchmark(c: &mut Criterion) { group.finish(); } +// ============================================================================ +// Benchmark Category: Snapshot Files +// ============================================================================ + +fn snapshot_file_benchmark(c: &mut Criterion) { + use hyperlight_host::sandbox::snapshot::Snapshot; + + let mut group = c.benchmark_group("snapshot_files"); + + // Pre-create snapshot files for all sizes + let dirs: Vec<_> = SandboxSize::all() + .iter() + .map(|size| { + let dir = tempfile::tempdir().unwrap(); + let snap_path = dir.path().join(format!("{}.hls", size.name())); + let snapshot = { + let mut sbox = create_multiuse_sandbox_with_size(*size); + sbox.snapshot().unwrap() + }; + snapshot.to_file(&snap_path).unwrap(); + (dir, snapshot) + }) + .collect(); + + // Benchmark: save_snapshot + for (i, size) in SandboxSize::all().iter().enumerate() { + let snap_dir = tempfile::tempdir().unwrap(); + let path = snap_dir.path().join("bench.hls"); + let snapshot = &dirs[i].1; + group.bench_function(format!("save_snapshot/{}", size.name()), |b| { + b.iter(|| { + snapshot.to_file(&path).unwrap(); + }); + }); + } + + // Benchmark: load_snapshot (mmap + header parse + hash verify) + for (i, size) in SandboxSize::all().iter().enumerate() { + let snap_path = dirs[i].0.path().join(format!("{}.hls", size.name())); + group.bench_function(format!("load_snapshot/{}", size.name()), |b| { + b.iter(|| { + let _ = Snapshot::from_file(&snap_path).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].0.path().join(format!("{}.hls", size.name())); + group.bench_function(format!("cold_start_via_snapshot/{}", size.name()), |b| { + b.iter(|| { + let loaded = Snapshot::from_file(&snap_path).unwrap(); + let mut sbox = MultiUseSandbox::from_snapshot(std::sync::Arc::new(loaded)).unwrap(); + sbox.call::("Echo", "hello\n".to_string()).unwrap(); + }); + }); + } + + // Benchmark: cold_start_via_snapshot_unchecked (no hash verify) + for (i, size) in SandboxSize::all().iter().enumerate() { + let snap_path = dirs[i].0.path().join(format!("{}.hls", size.name())); + group.bench_function( + format!("cold_start_via_snapshot_unchecked/{}", size.name()), + |b| { + b.iter(|| { + let loaded = Snapshot::from_file_unchecked(&snap_path).unwrap(); + let mut sbox = + MultiUseSandbox::from_snapshot(std::sync::Arc::new(loaded)).unwrap(); + sbox.call::("Echo", "hello\n".to_string()).unwrap(); + }); + }, + ); + } + + group.finish(); +} + criterion_group! { name = benches; config = Criterion::default(); @@ -566,6 +653,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); 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 fe2c7d2ae..780249f46 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs @@ -352,6 +352,15 @@ impl HyperlightVm { self.vm.set_debug_regs(&CommonDebugRegs::default())?; self.vm.reset_xsave()?; + self.apply_sregs(cr3, sregs) + } + + /// Apply special registers and mark TLB for flush. + pub(crate) fn apply_sregs( + &mut self, + cr3: u64, + sregs: &CommonSpecialRegisters, + ) -> std::result::Result<(), RegisterError> { #[cfg(not(feature = "nanvix-unstable"))] { // Restore the full special registers from snapshot, but update CR3 diff --git a/src/hyperlight_host/src/mem/layout.rs b/src/hyperlight_host/src/mem/layout.rs index 198cc01a4..e99340a5f 100644 --- a/src/hyperlight_host/src/mem/layout.rs +++ b/src/hyperlight_host/src/mem/layout.rs @@ -225,16 +225,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 = "nanvix-unstable", 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 SandboxMemoryLayout { @@ -394,12 +394,6 @@ impl SandboxMemoryLayout { self.peb_file_mappings_offset() } - /// Get the offset in guest memory to the file_mappings pointer field. - #[cfg(feature = "nanvix-unstable")] - fn get_file_mappings_pointer_offset(&self) -> usize { - self.get_file_mappings_size_offset() + size_of::() - } - /// Get the offset in snapshot memory where the FileMappingInfo array starts /// (immediately after the PEB struct, within the same page). #[cfg(feature = "nanvix-unstable")] diff --git a/src/hyperlight_host/src/mem/memory_region.rs b/src/hyperlight_host/src/mem/memory_region.rs index 979b260dd..821477d64 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 b978b3475..048a1eea0 100644 --- a/src/hyperlight_host/src/mem/shared_mem.rs +++ b/src/hyperlight_host/src/mem/shared_mem.rs @@ -2029,6 +2029,221 @@ impl ReadonlySharedMemory { }) } + /// Create a `ReadonlySharedMemory` backed by a file on disk. + /// + /// The memory blob at `[offset..offset+len)` in the file is mapped + /// with copy-on-write semantics so that guest writes trigger + /// per-page CoW faults without modifying the backing file. + /// + /// The mapping is surrounded by guard pages, matching the + /// `ExclusiveSharedMemory::new` layout, so that `base_ptr()` and + /// `mem_size()` return the correct values via the `SharedMemory` + /// trait. + /// + /// - **Linux**: `mmap(MAP_PRIVATE)` for zero-copy file-backed CoW. + /// - **Windows**: `CreateFileMappingA(PAGE_WRITECOPY)` + + /// `MapViewOfFile(FILE_MAP_COPY)` for zero-copy file-backed CoW. + /// The returned `HostMapping` carries the file mapping handle so + /// the surrogate process can create its own view via + /// `MapViewOfFileNuma2`. + pub(crate) fn from_file(file: &std::fs::File, offset: u64, len: usize) -> Result { + if len == 0 { + return Err(new_error!( + "Cannot create file-backed shared memory with size 0" + )); + } + + let total_size = len.checked_add(2 * PAGE_SIZE_USIZE).ok_or_else(|| { + new_error!("Memory required for file-backed snapshot exceeded usize::MAX") + })?; + + #[cfg(target_os = "linux")] + { + Self::from_file_linux(file, offset, len, total_size) + } + #[cfg(target_os = "windows")] + { + // The Windows path maps the entire file from offset 0 and uses + // the header page as the leading guard page. This requires the + // memory blob to start at exactly PAGE_SIZE. + if offset as usize != PAGE_SIZE_USIZE { + return Err(new_error!( + "Windows from_file requires offset == PAGE_SIZE, got {}", + offset + )); + } + Self::from_file_windows(file, total_size) + } + } + + #[cfg(target_os = "linux")] + fn from_file_linux( + file: &std::fs::File, + offset: u64, + len: usize, + total_size: usize, + ) -> Result { + use std::ffi::c_void; + 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 fd = file.as_raw_fd(); + let offset: off_t = offset + .try_into() + .map_err(|_| new_error!("snapshot file offset {} exceeds off_t range", offset))?; + + // 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(), + )); + } + + // 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, + offset, + ) + }; + if mapped == MAP_FAILED { + unsafe { libc::munmap(base, total_size as size_t) }; + 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(ReadonlySharedMemory { + region: Arc::new(HostMapping { + ptr: base as *mut u8, + size: total_size, + }), + }) + } + + /// Windows implementation of file-backed read-only shared memory. + /// + /// The snapshot file layout is: + /// `[header (PAGE_SIZE)][memory blob][trailing padding (PAGE_SIZE)]`. + /// We create a read-only file mapping covering the entire file and + /// map a view of `len + 2*PAGE_SIZE` bytes starting at file offset 0. + /// The header becomes the leading guard page and the trailing padding + /// becomes the trailing guard page, both via + /// `VirtualProtect(PAGE_NOACCESS)`. This gives the standard + /// `HostMapping` layout: `[guard | usable | guard]`. + #[cfg(target_os = "windows")] + fn from_file_windows(file: &std::fs::File, total_size: usize) -> Result { + use std::os::windows::io::AsRawHandle; + + use windows::Win32::Foundation::HANDLE; + use windows::Win32::System::Memory::{ + CreateFileMappingA, FILE_MAP_READ, MapViewOfFile, PAGE_NOACCESS, PAGE_PROTECTION_FLAGS, + PAGE_READONLY, VirtualProtect, + }; + use windows::core::PCSTR; + + let file_handle = HANDLE(file.as_raw_handle()); + + // Create a read-only file mapping at the exact file size (pass 0,0). + // The file includes trailing PAGE_SIZE padding written by to_file(), + // so the file is at least offset + len + PAGE_SIZE = total_size bytes. + let handle = + unsafe { CreateFileMappingA(file_handle, None, PAGE_READONLY, 0, 0, PCSTR::null())? }; + + if handle.is_invalid() { + log_then_return!(HyperlightError::MemoryAllocationFailed( + Error::last_os_error().raw_os_error() + )); + } + + // Map exactly total_size (header + blob + trailing padding) bytes. + let addr = unsafe { MapViewOfFile(handle, FILE_MAP_READ, 0, 0, total_size) }; + if addr.Value.is_null() { + unsafe { + let _ = windows::Win32::Foundation::CloseHandle(handle); + } + log_then_return!(HyperlightError::MemoryAllocationFailed( + Error::last_os_error().raw_os_error() + )); + } + + let cleanup = |ptr: *mut c_void, handle: windows::Win32::Foundation::HANDLE| unsafe { + if let Err(e) = windows::Win32::System::Memory::UnmapViewOfFile( + windows::Win32::System::Memory::MEMORY_MAPPED_VIEW_ADDRESS { Value: ptr }, + ) { + tracing::error!("from_file_windows cleanup: UnmapViewOfFile failed: {:?}", e); + } + if let Err(e) = windows::Win32::Foundation::CloseHandle(handle) { + tracing::error!("from_file_windows cleanup: CloseHandle failed: {:?}", e); + } + }; + + // Set guard pages on both ends. + let mut unused_old_prot = PAGE_PROTECTION_FLAGS(0); + + let first_guard = addr.Value; + if let Err(e) = unsafe { + VirtualProtect( + first_guard, + PAGE_SIZE_USIZE, + PAGE_NOACCESS, + &mut unused_old_prot, + ) + } { + cleanup(addr.Value, handle); + log_then_return!(WindowsAPIError(e.clone())); + } + + let last_guard = unsafe { first_guard.add(total_size - PAGE_SIZE_USIZE) }; + if let Err(e) = unsafe { + VirtualProtect( + last_guard, + PAGE_SIZE_USIZE, + PAGE_NOACCESS, + &mut unused_old_prot, + ) + } { + cleanup(addr.Value, handle); + log_then_return!(WindowsAPIError(e.clone())); + } + + #[allow(clippy::arc_with_non_send_sync)] + Ok(ReadonlySharedMemory { + region: Arc::new(HostMapping { + ptr: addr.Value as *mut u8, + size: total_size, + handle, + }), + }) + } + pub(crate) fn as_slice(&self) -> &[u8] { unsafe { std::slice::from_raw_parts(self.base_ptr(), self.mem_size()) } } diff --git a/src/hyperlight_host/src/sandbox/host_funcs.rs b/src/hyperlight_host/src/sandbox/host_funcs.rs index a1430338b..bc077f659 100644 --- a/src/hyperlight_host/src/sandbox/host_funcs.rs +++ b/src/hyperlight_host/src/sandbox/host_funcs.rs @@ -72,6 +72,23 @@ impl FunctionRegistry { Ok(()) } + /// Create a `FunctionRegistry` pre-populated with the default + /// `HostPrint` function (writes to stdout with green text). + pub(crate) fn with_default_host_print() -> Result { + 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)?; + Ok(registry) + } + /// Assuming a host function called `"HostPrint"` exists, and takes a /// single string parameter, call it with the given `msg` parameter. /// @@ -118,7 +135,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/initialized_multi_use.rs b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs index 72de96035..2979d779e 100644 --- a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs +++ b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs @@ -121,6 +121,142 @@ impl MultiUseSandbox { } } + /// 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. + /// + /// A default `HostPrint` host function is registered automatically. + /// Registering additional host functions is not currently supported + /// on this path; use the [`evolve()`](crate::UninitializedSandbox::evolve) + /// path if you need custom host functions. + /// + /// # Examples + /// + /// From a snapshot taken on another sandbox: + /// + /// ```no_run + /// # use std::sync::Arc; + /// # use hyperlight_host::{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)?; + /// let result: i32 = sandbox2.call("GetValue", ())?; + /// # Ok(()) + /// # } + /// ``` + /// + /// From a snapshot loaded from disk: + /// + /// ```no_run + /// # use std::sync::Arc; + /// # use hyperlight_host::MultiUseSandbox; + /// # use hyperlight_host::sandbox::snapshot::Snapshot; + /// # fn example() -> Result<(), Box> { + /// let snapshot = Arc::new(Snapshot::from_file("guest_snapshot.hls")?); + /// let mut sandbox = MultiUseSandbox::from_snapshot(snapshot)?; + /// 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) -> Result { + use rand::RngExt; + + use crate::mem::ptr::RawPtr; + use crate::sandbox::uninitialized_evolve::set_up_hypervisor_partition; + + let host_funcs = Arc::new(Mutex::new(FunctionRegistry::with_default_host_print()?)); + + let stack_top_gva = snapshot.stack_top_gva(); + let mut config = crate::sandbox::SandboxConfiguration::default(); + 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)?; + + let mut vm = set_up_hypervisor_partition( + gshm, + &config, + stack_top_gva, + page_size, + #[cfg(any(crashdump, gdb))] + Default::default(), + 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 = "nanvix-unstable"))] + if matches!(snapshot.entrypoint(), super::snapshot::NextAction::Call(_)) { + let sregs = snapshot.sregs().cloned().unwrap_or_else(|| { + crate::hypervisor::regs::CommonSpecialRegisters::standard_64bit_defaults( + hshm.layout.get_pt_base_gpa(), + ) + }); + 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())); + + Ok(MultiUseSandbox::from_uninit( + host_funcs, + hshm, + vm, + #[cfg(gdb)] + dbg_mem_wrapper, + )) + } + /// Creates a snapshot of the sandbox's current memory state. /// /// The snapshot is tied to this specific sandbox instance and can only be diff --git a/src/hyperlight_host/src/sandbox/snapshot.rs b/src/hyperlight_host/src/sandbox/snapshot.rs index c5f0520a6..12f39f23a 100644 --- a/src/hyperlight_host/src/sandbox/snapshot.rs +++ b/src/hyperlight_host/src/sandbox/snapshot.rs @@ -568,6 +568,691 @@ impl PartialEq for Snapshot { } } +// --- Snapshot file format --- +// +// All multi-byte integers are little-endian. The header is zero-padded +// to 4096 bytes so the memory blob is page-aligned for direct mmap. +// +// Preamble (fixed across all format versions): +// +// Offset Size Field +// ------ ---- ----- +// 0 4 Magic ("HLS\0") +// 4 4 Format version (u32: 1 = V1) +// +// V1 header (starts at offset 8): +// +// Offset Size Field +// ------ ---- ----- +// 8 4 Architecture tag (u32: 1=x86_64, 2=aarch64) +// 12 4 ABI version (u32: must match SNAPSHOT_ABI_VERSION) +// 16 32 Content hash (blake3, over memory blob only) +// 48 8 stack_top_gva (u64) +// 56 8 Entrypoint tag (u64: 0=Initialise, 1=Call) +// 64 8 Entrypoint address (u64) +// 72 8 input_data_size (u64) +// 80 8 output_data_size (u64) +// 88 8 heap_size (u64) +// 96 8 code_size (u64) +// 104 8 init_data_size (u64) +// 112 8 init_data_permissions (u64: 0=None, else MemoryRegionFlags bits) +// 120 8 scratch_size (u64) +// 128 8 snapshot_size (u64) +// 136 8 pt_size (u64: 0=None) +// 144 8 memory_size (u64, byte length of memory blob) +// Currently derivable from layout fields, but stored +// explicitly for forward compatibility (e.g. compression +// could make the on-disk size differ from the layout size). +// 152 8 memory_offset (u64, byte offset of memory blob from file start) +// Currently always SNAPSHOT_HEADER_SIZE (4096), but stored +// explicitly so a future format version can relocate the +// blob (e.g. for 2 MB huge page alignment) without a +// breaking change. +// 160 8 has_sregs (u64: 0=no, 1=yes) +// 168 8 hypervisor_tag (u64: 1=KVM, 2=MSHV, 3=WHP) +// +// Special registers (offset 176, always written; ignored on load if has_sregs=0): +// +// 176 832 8 segment registers (cs,ds,es,fs,gs,ss,tr,ldt) +// each: 13 fields x u64 (base, limit, selector, type, +// present, dpl, db, s, l, g, avl, unusable, padding) +// 1008 32 2 table registers (gdt, idt), each: base(u64) + limit(u64) +// 1040 56 7 control values: cr0, cr2, cr3, cr4, cr8, efer, apic_base +// 1096 32 interrupt_bitmap (4 x u64) +// +// Padding and memory blob: +// +// 1128 2968 Zero padding (to align memory blob to page boundary) +// 4096 * Memory blob (snapshot memory contents, mmap target) + +const SNAPSHOT_MAGIC: &[u8; 4] = b"HLS\0"; +const SNAPSHOT_HEADER_SIZE: usize = 4096; + +/// ABI version for the snapshot memory blob. This must be bumped +/// whenever a change affects the contents or interpretation of the +/// memory blob - i.e., the contract between the host runtime and +/// the guest binary that determines how snapshot memory is produced +/// and consumed. +/// +/// Examples of changes that require a bump: +/// +/// - Memory layout: `SandboxMemoryLayout` offset computation, memory +/// region definitions, page table format +/// - Host-guest interface: PEB struct layout, calling convention, +/// dispatch mechanism, input/output buffer format +/// - Guest init state: entry point setup, GDT/IDT/TSS initialization, +/// or any startup code in `hyperlight_guest_bin` whose results are +/// captured in the snapshot (e.g. sregs) +/// +/// Unlike `FormatVersion` (which covers the file header byte layout +/// and may allow conversion between versions), an ABI mismatch means +/// the memory blob is incompatible and the snapshot must be +/// regenerated from the guest binary. +const SNAPSHOT_ABI_VERSION: u32 = 1; + +/// Snapshot file format version. +#[derive(Copy, Clone, Debug, PartialEq)] +enum FormatVersion { + V1 = 1, +} + +impl FormatVersion { + fn from_u32(v: u32) -> crate::Result { + match v { + 1 => Ok(Self::V1), + _ => Err(crate::new_error!( + "unsupported snapshot format version {} (this build supports V1). \ + The file header layout may be convertible to the current format", + v + )), + } + } +} + +/// Architecture tag for snapshot files. +#[derive(Copy, Clone, Debug, PartialEq)] +enum ArchTag { + X86_64 = 1, + Aarch64 = 2, +} + +impl ArchTag { + fn current() -> Self { + #[cfg(target_arch = "x86_64")] + { + Self::X86_64 + } + #[cfg(target_arch = "aarch64")] + { + Self::Aarch64 + } + } + + fn from_u32(v: u32) -> crate::Result { + match v { + 1 => Ok(Self::X86_64), + 2 => Ok(Self::Aarch64), + _ => Err(crate::new_error!("unknown architecture tag: {}", v)), + } + } +} + +/// Hypervisor tag for snapshot files. +/// +/// Segment register hidden-cache fields (unusable, type_, granularity, +/// db) differ between hypervisors for the same architectural state. +/// Restoring sregs captured on one hypervisor into another may be +/// rejected or produce subtly wrong behavior. The tag ensures +/// snapshots are only loaded on the same hypervisor that created them. +#[derive(Copy, Clone, Debug, PartialEq)] +enum HypervisorTag { + Kvm = 1, + Mshv = 2, + Whp = 3, +} + +impl HypervisorTag { + 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 from_u64(v: u64) -> crate::Result { + match v { + 1 => Ok(Self::Kvm), + 2 => Ok(Self::Mshv), + 3 => Ok(Self::Whp), + _ => Err(crate::new_error!("unknown hypervisor tag: {}", v)), + } + } + + fn name(&self) -> &'static str { + match self { + Self::Kvm => "KVM", + Self::Mshv => "MSHV", + Self::Whp => "WHP", + } + } +} + +/// Memory layout fields stored in the snapshot file. +/// These are the primary inputs needed to reconstruct a `SandboxMemoryLayout`. +struct LayoutFields { + input_data_size: usize, + output_data_size: usize, + heap_size: usize, + code_size: usize, + init_data_size: usize, + init_data_permissions: Option, + scratch_size: usize, + snapshot_size: usize, + pt_size: Option, +} + +/// Fixed preamble at the start of every snapshot file. +/// This never changes across format versions so it can always be read +/// to determine which version-specific header follows. +struct SnapshotPreamble { + magic: [u8; 4], + format_version: FormatVersion, +} + +/// Version-specific header content. +enum SnapshotHeader { + V1(SnapshotHeaderV1), +} + +/// V1 snapshot header. +struct SnapshotHeaderV1 { + arch: ArchTag, + abi_version: u32, + hash: [u8; 32], + stack_top_gva: u64, + entrypoint: NextAction, + layout: LayoutFields, + memory_size: usize, + memory_offset: u64, + has_sregs: bool, + hypervisor: HypervisorTag, +} + +// --- Low-level I/O helpers --- + +fn write_u32(w: &mut impl std::io::Write, v: u32) -> crate::Result<()> { + w.write_all(&v.to_le_bytes()) + .map_err(|e| crate::new_error!("snapshot write error: {}", e)) +} + +fn write_u64(w: &mut impl std::io::Write, v: u64) -> crate::Result<()> { + w.write_all(&v.to_le_bytes()) + .map_err(|e| crate::new_error!("snapshot write error: {}", e)) +} + +fn read_u32(r: &mut (impl std::io::Read + ?Sized)) -> crate::Result { + let mut buf = [0u8; 4]; + r.read_exact(&mut buf) + .map_err(|e| crate::new_error!("snapshot read error: {}", e))?; + Ok(u32::from_le_bytes(buf)) +} + +fn read_u64(r: &mut (impl std::io::Read + ?Sized)) -> crate::Result { + let mut buf = [0u8; 8]; + r.read_exact(&mut buf) + .map_err(|e| crate::new_error!("snapshot read error: {}", e))?; + Ok(u64::from_le_bytes(buf)) +} + +fn read_bytes(r: &mut (impl std::io::Read + ?Sized)) -> crate::Result<[u8; N]> { + let mut buf = [0u8; N]; + r.read_exact(&mut buf) + .map_err(|e| crate::new_error!("snapshot file truncated: {}", e))?; + Ok(buf) +} + +// --- Preamble serialization --- + +impl SnapshotPreamble { + fn write_to(&self, w: &mut impl std::io::Write) -> crate::Result<()> { + w.write_all(&self.magic) + .map_err(|e| crate::new_error!("snapshot write error: {}", e))?; + write_u32(w, self.format_version as u32) + } + + fn read_from(r: &mut (impl std::io::Read + ?Sized)) -> crate::Result { + Ok(Self { + magic: read_bytes(r)?, + format_version: FormatVersion::from_u32(read_u32(r)?)?, + }) + } +} + +// --- V1 header serialization --- + +impl SnapshotHeaderV1 { + fn write_to(&self, w: &mut impl std::io::Write) -> crate::Result<()> { + write_u32(w, self.arch as u32)?; + write_u32(w, self.abi_version)?; + w.write_all(&self.hash) + .map_err(|e| crate::new_error!("snapshot write error: {}", e))?; + write_u64(w, self.stack_top_gva)?; + let (tag, addr) = match self.entrypoint { + NextAction::Initialise(a) => (0u64, a), + NextAction::Call(a) => (1u64, a), + #[cfg(test)] + NextAction::None => (u64::MAX, 0), + }; + write_u64(w, tag)?; + write_u64(w, addr)?; + + // Layout fields + let l = &self.layout; + write_u64(w, l.input_data_size as u64)?; + write_u64(w, l.output_data_size as u64)?; + write_u64(w, l.heap_size as u64)?; + write_u64(w, l.code_size as u64)?; + write_u64(w, l.init_data_size as u64)?; + write_u64(w, l.init_data_permissions.map_or(0, |f| f.bits() as u64))?; + write_u64(w, l.scratch_size as u64)?; + write_u64(w, l.snapshot_size as u64)?; + write_u64(w, l.pt_size.map_or(0, |v| v as u64))?; + + write_u64(w, self.memory_size as u64)?; + write_u64(w, self.memory_offset)?; + write_u64(w, if self.has_sregs { 1 } else { 0 })?; + write_u64(w, self.hypervisor as u64)?; + Ok(()) + } + + fn read_from(r: &mut (impl std::io::Read + ?Sized)) -> crate::Result { + use crate::mem::memory_region::MemoryRegionFlags; + + let arch = ArchTag::from_u32(read_u32(r)?)?; + let abi_version = read_u32(r)?; + let hash = read_bytes(r)?; + let stack_top_gva = read_u64(r)?; + let entrypoint_tag = read_u64(r)?; + let entrypoint_addr = read_u64(r)?; + let entrypoint = match entrypoint_tag { + 0 => NextAction::Initialise(entrypoint_addr), + 1 => NextAction::Call(entrypoint_addr), + _ => { + return Err(crate::new_error!( + "invalid entrypoint tag in snapshot: {}", + entrypoint_tag + )); + } + }; + + let input_data_size = read_u64(r)? as usize; + let output_data_size = read_u64(r)? as usize; + let heap_size = read_u64(r)? as usize; + let code_size = read_u64(r)? as usize; + let init_data_size = read_u64(r)? as usize; + let perms_raw = read_u64(r)?; + let init_data_permissions = if perms_raw == 0 { + None + } else { + Some( + MemoryRegionFlags::from_bits(perms_raw as u32).ok_or_else(|| { + crate::new_error!( + "snapshot contains unknown memory region flags: {:#x}", + perms_raw + ) + })?, + ) + }; + let scratch_size = read_u64(r)? as usize; + let snapshot_size = read_u64(r)? as usize; + let pt_raw = read_u64(r)?; + let pt_size = if pt_raw == 0 { + None + } else { + Some(pt_raw as usize) + }; + + let memory_size = read_u64(r)? as usize; + let memory_offset = read_u64(r)?; + let has_sregs = read_u64(r)? != 0; + let hypervisor = HypervisorTag::from_u64(read_u64(r)?)?; + + Ok(Self { + arch, + abi_version, + hash, + stack_top_gva, + entrypoint, + layout: LayoutFields { + input_data_size, + output_data_size, + heap_size, + code_size, + init_data_size, + init_data_permissions, + scratch_size, + snapshot_size, + pt_size, + }, + memory_size, + memory_offset, + has_sregs, + hypervisor, + }) + } +} + +fn write_sregs(w: &mut impl std::io::Write, sregs: &CommonSpecialRegisters) -> crate::Result<()> { + // Segment registers: cs, ds, es, fs, gs, ss, tr, ldt (13 fields each) + for seg in [ + &sregs.cs, &sregs.ds, &sregs.es, &sregs.fs, &sregs.gs, &sregs.ss, &sregs.tr, &sregs.ldt, + ] { + for v in [ + seg.base, + seg.limit as u64, + seg.selector as u64, + seg.type_ as u64, + seg.present as u64, + seg.dpl as u64, + seg.db as u64, + seg.s as u64, + seg.l as u64, + seg.g as u64, + seg.avl as u64, + seg.unusable as u64, + seg.padding as u64, + ] { + write_u64(w, v)?; + } + } + // Table registers: gdt, idt (2 fields each) + for tab in [&sregs.gdt, &sregs.idt] { + write_u64(w, tab.base)?; + write_u64(w, tab.limit as u64)?; + } + // Control registers + bitmap + for v in [ + sregs.cr0, + sregs.cr2, + sregs.cr3, + sregs.cr4, + sregs.cr8, + sregs.efer, + sregs.apic_base, + ] { + write_u64(w, v)?; + } + for &v in &sregs.interrupt_bitmap { + write_u64(w, v)?; + } + Ok(()) +} + +fn read_sregs(r: &mut impl std::io::Read) -> crate::Result { + use crate::hypervisor::regs::{CommonSegmentRegister, CommonTableRegister}; + + let read_seg = |r: &mut dyn std::io::Read| -> crate::Result { + Ok(CommonSegmentRegister { + base: read_u64(r)?, + limit: read_u64(r)? as u32, + selector: read_u64(r)? as u16, + type_: read_u64(r)? as u8, + present: read_u64(r)? as u8, + dpl: read_u64(r)? as u8, + db: read_u64(r)? as u8, + s: read_u64(r)? as u8, + l: read_u64(r)? as u8, + g: read_u64(r)? as u8, + avl: read_u64(r)? as u8, + unusable: read_u64(r)? as u8, + padding: read_u64(r)? as u8, + }) + }; + let read_tab = |r: &mut dyn std::io::Read| -> crate::Result { + Ok(CommonTableRegister { + base: read_u64(r)?, + limit: read_u64(r)? as u16, + }) + }; + Ok(CommonSpecialRegisters { + cs: read_seg(r)?, + ds: read_seg(r)?, + es: read_seg(r)?, + fs: read_seg(r)?, + gs: read_seg(r)?, + ss: read_seg(r)?, + tr: read_seg(r)?, + ldt: read_seg(r)?, + gdt: read_tab(r)?, + idt: read_tab(r)?, + cr0: read_u64(r)?, + cr2: read_u64(r)?, + cr3: read_u64(r)?, + cr4: read_u64(r)?, + cr8: read_u64(r)?, + efer: read_u64(r)?, + apic_base: read_u64(r)?, + interrupt_bitmap: [read_u64(r)?, read_u64(r)?, read_u64(r)?, read_u64(r)?], + }) +} + +impl Snapshot { + /// Save this snapshot to a file on disk. + /// + /// The file format uses a page-aligned memory blob that can be + /// mmapped directly on load for zero-copy instantiation. + /// + /// Note: extra memory regions added via + /// [`map_region`](crate::MultiUseSandbox::map_region) or + /// [`map_file_cow`](crate::MultiUseSandbox::map_file_cow) are + /// **not** persisted. Only the primary sandbox memory is saved. + /// Regions that were folded into the snapshot memory (by taking + /// a snapshot after mapping) are included since they become part + /// of the memory blob. + pub fn to_file(&self, path: impl AsRef) -> crate::Result<()> { + use std::io::{BufWriter, Write}; + + let file = std::fs::File::create(path.as_ref()) + .map_err(|e| crate::new_error!("failed to create snapshot file: {}", e))?; + let mut w = BufWriter::new(file); + + let layout = &self.layout; + + let preamble = SnapshotPreamble { + magic: *SNAPSHOT_MAGIC, + format_version: FormatVersion::V1, + }; + + let v1 = SnapshotHeaderV1 { + arch: ArchTag::current(), + abi_version: SNAPSHOT_ABI_VERSION, + hash: self.hash, + stack_top_gva: self.stack_top_gva, + entrypoint: self.entrypoint, + layout: LayoutFields { + input_data_size: layout.input_data_size, + output_data_size: layout.output_data_size, + heap_size: layout.heap_size, + code_size: layout.code_size, + init_data_size: layout.init_data_size, + init_data_permissions: layout.init_data_permissions, + scratch_size: layout.get_scratch_size(), + snapshot_size: layout.snapshot_size, + pt_size: layout.pt_size, + }, + memory_size: self.memory.mem_size(), + memory_offset: SNAPSHOT_HEADER_SIZE as u64, + has_sregs: self.sregs.is_some(), + hypervisor: HypervisorTag::current() + .ok_or_else(|| crate::new_error!("no hypervisor available to tag snapshot"))?, + }; + + preamble.write_to(&mut w)?; + v1.write_to(&mut w)?; + write_sregs(&mut w, &self.sregs.unwrap_or_default())?; + + // Pad header to SNAPSHOT_HEADER_SIZE and write memory blob + // Use a Cursor to track position instead of manual size calculation + let pos = std::io::Seek::stream_position(&mut w) + .map_err(|e| crate::new_error!("snapshot seek error: {}", e))? + as usize; + if pos > SNAPSHOT_HEADER_SIZE { + return Err(crate::new_error!( + "snapshot header exceeded {} bytes (wrote {})", + SNAPSHOT_HEADER_SIZE, + pos + )); + } + w.write_all(&vec![0u8; SNAPSHOT_HEADER_SIZE - pos]) + .map_err(|e| crate::new_error!("snapshot write error: {}", e))?; + + w.write_all(self.memory.as_slice()) + .map_err(|e| crate::new_error!("snapshot write error: {}", e))?; + + // Trailing PAGE_SIZE padding: Windows read-only file mappings + // cannot extend beyond the file's actual size, so the file must + // contain backing bytes for the trailing guard page used by + // ReadonlySharedMemory::from_file_windows. Linux ignores this + // padding (its guard pages come from an anonymous mmap reservation). + w.write_all(&[0u8; PAGE_SIZE]) + .map_err(|e| crate::new_error!("snapshot write error: {}", e))?; + + w.flush() + .map_err(|e| crate::new_error!("snapshot write error: {}", e))?; + + Ok(()) + } + + /// Load a snapshot from a file on disk. + /// + /// The memory blob is mapped directly from the file for zero-copy + /// loading using platform-specific CoW mechanisms. + /// + /// Note: ELF unwind info (`LoadInfo`) is not persisted in the + /// snapshot file, so the `mem_profile` feature will not have + /// accurate profiling data for sandboxes created from disk + /// snapshots. + pub fn from_file(path: impl AsRef) -> crate::Result { + Self::from_file_impl(path, true) + } + + /// Load a snapshot from a file on disk without verifying the + /// content hash. This is faster for large snapshots in trusted + /// environments where file integrity is guaranteed by other means. + pub fn from_file_unchecked(path: impl AsRef) -> crate::Result { + Self::from_file_impl(path, false) + } + + fn from_file_impl(path: impl AsRef, verify_hash: bool) -> crate::Result { + use std::io::BufReader; + + let file = std::fs::File::open(path.as_ref()) + .map_err(|e| crate::new_error!("failed to open snapshot file: {}", e))?; + let mut r = BufReader::new(&file); + + // Read preamble first to determine version + let preamble = SnapshotPreamble::read_from(&mut r)?; + if &preamble.magic != SNAPSHOT_MAGIC { + return Err(crate::new_error!( + "invalid snapshot file: bad magic bytes (expected {:?}, got {:?})", + SNAPSHOT_MAGIC, + preamble.magic + )); + } + + // Dispatch to version-specific reader + let header = match preamble.format_version { + FormatVersion::V1 => SnapshotHeader::V1(SnapshotHeaderV1::read_from(&mut r)?), + }; + + let SnapshotHeader::V1(hdr) = header; + + // Validate + if hdr.arch != ArchTag::current() { + return Err(crate::new_error!( + "snapshot architecture mismatch: expected {:?}, got {:?}", + ArchTag::current(), + hdr.arch + )); + } + if hdr.abi_version != SNAPSHOT_ABI_VERSION { + return Err(crate::new_error!( + "snapshot ABI version mismatch: file has ABI version {}, \ + but this build expects {}. The snapshot must be regenerated \ + from the guest binary.", + hdr.abi_version, + SNAPSHOT_ABI_VERSION + )); + } + let current_hv = HypervisorTag::current() + .ok_or_else(|| crate::new_error!("no hypervisor available to load snapshot"))?; + if hdr.hypervisor != current_hv { + return Err(crate::new_error!( + "snapshot hypervisor mismatch: file was created on {} but the current hypervisor is {}.", + hdr.hypervisor.name(), + current_hv.name() + )); + } + + // Reconstruct layout + let l = &hdr.layout; + let mut cfg = crate::sandbox::SandboxConfiguration::default(); + cfg.set_input_data_size(l.input_data_size); + cfg.set_output_data_size(l.output_data_size); + cfg.set_heap_size(l.heap_size as u64); + cfg.set_scratch_size(l.scratch_size); + let mut layout = + SandboxMemoryLayout::new(cfg, l.code_size, l.init_data_size, l.init_data_permissions)?; + layout.set_snapshot_size(l.snapshot_size); + if let Some(pt) = l.pt_size { + layout.set_pt_size(pt)?; + } + + // Read sregs + let sregs_data = read_sregs(&mut r)?; + let sregs = if hdr.has_sregs { + Some(sregs_data) + } else { + None + }; + + // Map the memory blob directly from the file (zero-copy CoW) + let memory = ReadonlySharedMemory::from_file(&file, hdr.memory_offset, hdr.memory_size)?; + + // Verify hash + if verify_hash { + let computed: [u8; 32] = blake3::hash(memory.as_slice()).into(); + if computed != hdr.hash { + return Err(crate::new_error!( + "snapshot hash mismatch: file may be corrupted" + )); + } + } + + 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(), + hash: hdr.hash, + stack_top_gva: hdr.stack_top_gva, + sregs, + entrypoint: hdr.entrypoint, + }) + } +} + #[cfg(test)] mod tests { use hyperlight_common::vmem::{self, BasicMapping, Mapping, MappingKind, PAGE_SIZE}; @@ -673,3 +1358,396 @@ mod tests { .unwrap(); } } + +#[cfg(test)] +mod snapshot_file_tests { + use std::sync::Arc; + + use hyperlight_testing::simple_guest_as_string; + + use crate::sandbox::snapshot::Snapshot; + use crate::{GuestBinary, 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() + } + + #[test] + fn from_snapshot_already_initialized_in_memory() { + // Test from_snapshot with a snapshot taken from an already-initialized + // sandbox (NextAction::Call), directly from memory without file I/O + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + + let new_snap = Snapshot { + sandbox_id: super::SANDBOX_CONFIGURATION_COUNTER + .fetch_add(1, std::sync::atomic::Ordering::Relaxed), + layout: *snapshot.layout(), + memory: snapshot.memory().clone(), + regions: snapshot.regions().to_vec(), + load_info: snapshot.load_info(), + hash: snapshot.hash, + stack_top_gva: snapshot.stack_top_gva(), + sregs: snapshot.sregs().cloned(), + entrypoint: snapshot.entrypoint(), + }; + + let mut sbox2 = MultiUseSandbox::from_snapshot(Arc::new(new_snap)).unwrap(); + let result: i32 = sbox2.call("GetStatic", ()).unwrap(); + assert_eq!(result, 0); + } + + #[test] + fn from_snapshot_in_memory() { + // Test from_snapshot pathway using the existing Snapshot::from_env + let path = simple_guest_as_string().unwrap(); + let snap = Snapshot::from_env( + GuestBinary::FilePath(path), + crate::sandbox::SandboxConfiguration::default(), + ) + .unwrap(); + + let mut sbox = MultiUseSandbox::from_snapshot(Arc::new(snap)).unwrap(); + + // from_env creates a snapshot with NextAction::Initialise, + // so from_snapshot will run the init code via vm.initialise() + let result: i32 = sbox.call("GetStatic", ()).unwrap(); + assert_eq!(result, 0); + } + + #[test] + fn round_trip_save_load_call() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let snap_path = dir.path().join("test.hls"); + snapshot.to_file(&snap_path).unwrap(); + + let loaded = Snapshot::from_file(&snap_path).unwrap(); + let mut sbox2 = MultiUseSandbox::from_snapshot(Arc::new(loaded)).unwrap(); + + let result: String = sbox2.call("Echo", "hello\n".to_string()).unwrap(); + assert_eq!(result, "hello\n"); + } + + #[test] + fn hash_verification_detects_corruption() { + let snapshot = create_snapshot_from_binary(); + + let dir = tempfile::tempdir().unwrap(); + let snap_path = dir.path().join("corrupted.hls"); + snapshot.to_file(&snap_path).unwrap(); + + // Corrupt a byte in the memory blob (after the 4096-byte header) + { + use std::io::{Read, Seek, SeekFrom, Write}; + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&snap_path) + .unwrap(); + file.seek(SeekFrom::Start(4096 + 100)).unwrap(); + let mut byte = [0u8; 1]; + file.read_exact(&mut byte).unwrap(); + byte[0] ^= 0xFF; + file.seek(SeekFrom::Start(4096 + 100)).unwrap(); + file.write_all(&byte).unwrap(); + } + + let result = Snapshot::from_file(&snap_path); + let err_msg = match result { + Err(e) => format!("{}", e), + Ok(_) => panic!("expected load to fail with hash mismatch"), + }; + assert!( + err_msg.contains("hash mismatch"), + "expected hash mismatch error, got: {}", + err_msg + ); + } + + #[test] + fn arch_mismatch_rejected() { + let snapshot = create_snapshot_from_binary(); + + let dir = tempfile::tempdir().unwrap(); + let snap_path = dir.path().join("wrong_arch.hls"); + snapshot.to_file(&snap_path).unwrap(); + + // Overwrite the architecture tag (offset 8, 4 bytes) + { + use std::io::{Seek, SeekFrom, Write}; + let mut file = std::fs::OpenOptions::new() + .write(true) + .open(&snap_path) + .unwrap(); + file.seek(SeekFrom::Start(8)).unwrap(); + file.write_all(&99u32.to_le_bytes()).unwrap(); + } + + let result = Snapshot::from_file(&snap_path); + let err_msg = match result { + Err(e) => format!("{}", e), + Ok(_) => panic!("expected load to fail with arch mismatch"), + }; + assert!( + err_msg.contains("architecture"), + "expected arch-related error, got: {}", + err_msg + ); + } + + #[test] + fn format_version_mismatch_rejected() { + let snapshot = create_snapshot_from_binary(); + + let dir = tempfile::tempdir().unwrap(); + let snap_path = dir.path().join("wrong_version.hls"); + snapshot.to_file(&snap_path).unwrap(); + + // Overwrite the format version (offset 4, 4 bytes) + { + use std::io::{Seek, SeekFrom, Write}; + let mut file = std::fs::OpenOptions::new() + .write(true) + .open(&snap_path) + .unwrap(); + file.seek(SeekFrom::Start(4)).unwrap(); + file.write_all(&999u32.to_le_bytes()).unwrap(); + } + + let result = Snapshot::from_file(&snap_path); + let err_msg = match result { + Err(e) => format!("{}", e), + Ok(_) => panic!("expected load to fail with version mismatch"), + }; + assert!( + err_msg.contains("format version"), + "expected version mismatch error, got: {}", + err_msg + ); + assert!( + err_msg.contains("convertible"), + "expected hint about convertibility, got: {}", + err_msg + ); + } + + #[test] + fn abi_version_mismatch_rejected() { + let snapshot = create_snapshot_from_binary(); + + let dir = tempfile::tempdir().unwrap(); + let snap_path = dir.path().join("wrong_abi.hls"); + snapshot.to_file(&snap_path).unwrap(); + + // Overwrite the ABI version (offset 12, 4 bytes) + { + use std::io::{Seek, SeekFrom, Write}; + let mut file = std::fs::OpenOptions::new() + .write(true) + .open(&snap_path) + .unwrap(); + file.seek(SeekFrom::Start(12)).unwrap(); + file.write_all(&999u32.to_le_bytes()).unwrap(); + } + + let result = Snapshot::from_file(&snap_path); + let err_msg = match result { + Err(e) => format!("{}", e), + Ok(_) => panic!("expected load to fail with ABI version mismatch"), + }; + assert!( + err_msg.contains("ABI version mismatch"), + "expected ABI version mismatch error, got: {}", + err_msg + ); + assert!( + err_msg.contains("regenerated"), + "expected hint about regeneration, got: {}", + err_msg + ); + } + + #[test] + fn hypervisor_mismatch_rejected() { + let snapshot = create_snapshot_from_binary(); + + let dir = tempfile::tempdir().unwrap(); + let snap_path = dir.path().join("wrong_hv.hls"); + snapshot.to_file(&snap_path).unwrap(); + + // Overwrite the hypervisor tag (offset 168, 8 bytes) with a + // valid but wrong hypervisor tag. + use super::HypervisorTag; + let current = HypervisorTag::current().unwrap(); + let wrong_tag = match current { + HypervisorTag::Whp => HypervisorTag::Kvm, + _ => HypervisorTag::Whp, + }; + { + use std::io::{Seek, SeekFrom, Write}; + let mut file = std::fs::OpenOptions::new() + .write(true) + .open(&snap_path) + .unwrap(); + file.seek(SeekFrom::Start(168)).unwrap(); + file.write_all(&(wrong_tag as u64).to_le_bytes()).unwrap(); + } + + let result = Snapshot::from_file(&snap_path); + let err_msg = match result { + Err(e) => format!("{}", e), + Ok(_) => panic!("expected load to fail with hypervisor mismatch"), + }; + assert!( + err_msg.contains("hypervisor mismatch"), + "expected hypervisor mismatch error, got: {}", + err_msg + ); + } + + #[test] + fn restore_from_loaded_snapshot() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let snap_path = dir.path().join("restore.hls"); + snapshot.to_file(&snap_path).unwrap(); + + let loaded = Snapshot::from_file(&snap_path).unwrap(); + let mut sbox = MultiUseSandbox::from_snapshot(Arc::new(loaded)).unwrap(); + + // Mutate state + sbox.call::("AddToStatic", 42i32).unwrap(); + let val: i32 = sbox.call("GetStatic", ()).unwrap(); + assert_eq!(val, 42); + + // Take a new snapshot and restore to it + let snap2 = sbox.snapshot().unwrap(); + sbox.call::("AddToStatic", 10i32).unwrap(); + let val: i32 = sbox.call("GetStatic", ()).unwrap(); + assert_eq!(val, 52); + + sbox.restore(snap2).unwrap(); + let val: i32 = sbox.call("GetStatic", ()).unwrap(); + assert_eq!(val, 42); + } + + #[test] + fn multiple_sandboxes_from_same_file() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let snap_path = dir.path().join("shared.hls"); + snapshot.to_file(&snap_path).unwrap(); + + let loaded1 = Snapshot::from_file(&snap_path).unwrap(); + let loaded2 = Snapshot::from_file(&snap_path).unwrap(); + + let mut sbox1 = MultiUseSandbox::from_snapshot(Arc::new(loaded1)).unwrap(); + let mut sbox2 = MultiUseSandbox::from_snapshot(Arc::new(loaded2)).unwrap(); + + // Mutate one, verify the other is unaffected + sbox1.call::("AddToStatic", 100i32).unwrap(); + let val1: i32 = sbox1.call("GetStatic", ()).unwrap(); + let val2: i32 = sbox2.call("GetStatic", ()).unwrap(); + assert_eq!(val1, 100); + assert_eq!(val2, 0); + } + + #[test] + fn snapshot_then_save_round_trip() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let snap_path1 = dir.path().join("first.hls"); + snapshot.to_file(&snap_path1).unwrap(); + + // Load, create sandbox, mutate, take snapshot, save again + let loaded = Snapshot::from_file(&snap_path1).unwrap(); + let mut sbox2 = MultiUseSandbox::from_snapshot(Arc::new(loaded)).unwrap(); + + sbox2.call::("AddToStatic", 77i32).unwrap(); + let snap2 = sbox2.snapshot().unwrap(); + + let snap_path2 = dir.path().join("second.hls"); + snap2.to_file(&snap_path2).unwrap(); + + // Load the second snapshot and verify mutated state + let loaded2 = Snapshot::from_file(&snap_path2).unwrap(); + let mut sbox3 = MultiUseSandbox::from_snapshot(Arc::new(loaded2)).unwrap(); + + let val: i32 = sbox3.call("GetStatic", ()).unwrap(); + assert_eq!(val, 77); + } + + /// `MultiUseSandbox::from_snapshot` should register the default + /// `HostPrint` host function, just like the regular codepath. + #[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 snap_path = dir.path().join("test.hls"); + snapshot.to_file(&snap_path).unwrap(); + + let loaded = Snapshot::from_file(&snap_path).unwrap(); + let mut sbox2 = MultiUseSandbox::from_snapshot(Arc::new(loaded)).unwrap(); + + let result = sbox2.call::("PrintOutput", "hello from snapshot".to_string()); + assert!( + result.is_ok(), + "PrintOutput should succeed because HostPrint is registered by from_snapshot: {:?}", + result.unwrap_err() + ); + } + + #[test] + fn from_file_unchecked_skips_hash_verification() { + let mut sbox = create_test_sandbox(); + let snapshot = sbox.snapshot().unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let snap_path = dir.path().join("unchecked.hls"); + snapshot.to_file(&snap_path).unwrap(); + + // Corrupt a byte in the memory blob (past the header) + { + use std::io::{Seek, SeekFrom, Write}; + let mut file = std::fs::OpenOptions::new() + .write(true) + .open(&snap_path) + .unwrap(); + // Write garbage into the memory blob region + file.seek(SeekFrom::Start(4096 + 64)).unwrap(); + file.write_all(&[0xFF; 16]).unwrap(); + } + + // from_file (with hash check) should fail + let result = Snapshot::from_file(&snap_path); + assert!(result.is_err(), "from_file should detect corruption"); + + // from_file_unchecked should succeed despite corruption + let loaded = Snapshot::from_file_unchecked(&snap_path); + assert!(loaded.is_ok(), "from_file_unchecked should skip hash check"); + } +} diff --git a/src/hyperlight_host/src/sandbox/uninitialized.rs b/src/hyperlight_host/src/sandbox/uninitialized.rs index e737d08da..65d2ef299 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)