From 71770d552a8ffa81c8ca571ef0c8bc10fa933f51 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Wed, 22 Jul 2026 15:58:54 +0100 Subject: [PATCH 01/14] feat(hvf): add shared hyperlight_hvf crate with surrogate IPC protocol Introduce a new crate shared by hyperlight-host and the upcoming hvf_surrogate helper process: - core: thin wrapper over the Hypervisor.framework C API (VM/vCPU lifecycle, register access, vCPU run loop with ESR exit decoding) used by both the direct backend and the surrogate server. - proto: length-prefixed JSON IPC protocol (requests/responses, shm-backed memory references) for delegating a sandbox's VM to a surrogate process, needed because HVF allows only one VM per process. Signed-off-by: Sienna Meridian Satterwhite --- Cargo.lock | 23 ++ Cargo.toml | 2 + src/hyperlight_hvf/Cargo.toml | 26 ++ src/hyperlight_hvf/src/core.rs | 580 ++++++++++++++++++++++++++++++++ src/hyperlight_hvf/src/lib.rs | 32 ++ src/hyperlight_hvf/src/proto.rs | 211 ++++++++++++ 6 files changed, 874 insertions(+) create mode 100644 src/hyperlight_hvf/Cargo.toml create mode 100644 src/hyperlight_hvf/src/core.rs create mode 100644 src/hyperlight_hvf/src/lib.rs create mode 100644 src/hyperlight_hvf/src/proto.rs diff --git a/Cargo.lock b/Cargo.lock index 7db5b5dd0..08e6b60d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,6 +113,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "applevisor-sys" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14ae37676989768c5b21414857b782a5e513d93129cda34854f5f78c423a63be" +dependencies = [ + "libc", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -1663,6 +1672,7 @@ name = "hyperlight-host" version = "0.16.0" dependencies = [ "anyhow", + "applevisor-sys", "bitflags 2.13.1", "blake3", "built", @@ -1685,6 +1695,7 @@ dependencies = [ "hyperlight-common", "hyperlight-component-macro", "hyperlight-guest-tracing", + "hyperlight-hvf", "hyperlight-testing", "iced-x86", "kvm-bindings", @@ -1732,6 +1743,18 @@ dependencies = [ "windows-version", ] +[[package]] +name = "hyperlight-hvf" +version = "0.16.0" +dependencies = [ + "applevisor-sys", + "hyperlight-common", + "libc", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "hyperlight-libc" version = "0.16.0" diff --git a/Cargo.toml b/Cargo.toml index b82515ead..16fa78743 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "src/hyperlight_host", "src/hyperlight_guest_capi", "src/hyperlight_guest_tracing", + "src/hyperlight_hvf", "src/hyperlight_testing", "fuzz", "src/hyperlight_guest_bin", @@ -43,6 +44,7 @@ hyperlight-guest-bin = { path = "src/hyperlight_guest_bin", version = "0.16.0", hyperlight-guest-macro = { path = "src/hyperlight_guest_macro", version = "0.16.0", default-features = false } hyperlight-testing = { path = "src/hyperlight_testing", default-features = false } hyperlight-guest-tracing = { path = "src/hyperlight_guest_tracing", version = "0.16.0", default-features = false } +hyperlight-hvf = { path = "src/hyperlight_hvf", version = "0.16.0", default-features = false } hyperlight-libc = { path = "src/hyperlight_libc", version = "0.16.0", default-features = false } hyperlight-component-util = { path = "src/hyperlight_component_util", version = "0.16.0", default-features = false } hyperlight-component-macro = { path = "src/hyperlight_component_macro", version = "0.16.0", default-features = false } diff --git a/src/hyperlight_hvf/Cargo.toml b/src/hyperlight_hvf/Cargo.toml new file mode 100644 index 000000000..6c091be97 --- /dev/null +++ b/src/hyperlight_hvf/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "hyperlight-hvf" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +description = """ +Shared Hypervisor.framework (HVF) core and surrogate IPC protocol (macOS, aarch64). +Used by hyperlight-host (direct backend and surrogate client) and by the +hvf_surrogate binary (surrogate server). +""" + +[lints] +workspace = true + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2.0.18" + +[target.'cfg(all(target_os = "macos", target_arch = "aarch64"))'.dependencies] +applevisor-sys = "1.0" +hyperlight-common = { workspace = true, default-features = true, features = [ "std" ] } +libc = "0.2.186" diff --git a/src/hyperlight_hvf/src/core.rs b/src/hyperlight_hvf/src/core.rs new file mode 100644 index 000000000..bd8884a9c --- /dev/null +++ b/src/hyperlight_hvf/src/core.rs @@ -0,0 +1,580 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! Thin wrapper over the Hypervisor.framework C API. +//! +//! Threading: HVF requires that a vCPU is created, run, and accessed +//! (register reads/writes) from a single thread. The only exception is +//! cancellation: `hv_vcpus_exit` may be called from any thread. + +use std::collections::HashMap; +use std::ffi::c_void; + +use applevisor_sys::{ + HV_MEMORY_EXEC, HV_MEMORY_READ, HV_MEMORY_WRITE, hv_error_t, hv_exit_reason_t, + hv_ipa_granule_t, hv_reg_t, hv_simd_fp_reg_t, hv_sys_reg_t, hv_vcpu_create, hv_vcpu_destroy, + hv_vcpu_exit_t, hv_vcpu_get_reg, hv_vcpu_get_simd_fp_reg, hv_vcpu_get_sys_reg, hv_vcpu_run, + hv_vcpu_set_reg, hv_vcpu_set_simd_fp_reg, hv_vcpu_set_sys_reg, hv_vcpu_t, hv_vcpus_exit, + hv_vm_config_create, hv_vm_config_set_ipa_granule, hv_vm_create, hv_vm_destroy, hv_vm_map, + hv_vm_unmap, os_release, +}; +use hyperlight_common::outb::VmAction; +use serde::{Deserialize, Serialize}; + +/// ESR_EL2 exception classes (EC field, bits [31:26]) relevant to this backend. +const EC_WFI_WFE: u64 = 0x01; +const EC_INST_ABORT: u64 = 0x20; // instruction abort from a lower EL +const EC_DATA_ABORT: u64 = 0x24; // data abort from a lower EL + +/// ESR_EL2 ISS fields for aborts. +const ESR_ISV: u64 = 1 << 24; // instruction syndrome valid +const ESR_SAS_SHIFT: u64 = 22; // syndrome access size (log2 bytes) +const ESR_SRT_SHIFT: u64 = 16; // syndrome register transfer (X register index) +const ESR_WNR: u64 = 1 << 6; // write not read + +/// Error returned by HVF operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)] +pub enum HvfError { + /// A Hypervisor.framework call failed; payload is the `hv_return_t`. + #[error("HVF error: {0:#x}")] + Hv(u32), + /// A data abort to the IO page arrived without a valid instruction + /// syndrome, so the access details (register, size) cannot be decoded. + #[error("HVF data abort without valid instruction syndrome")] + NoInstructionSyndrome, +} + +/// Convert an `hv_return_t` into a `Result`. +fn check_hv(ret: i32) -> Result<(), HvfError> { + if ret == hv_error_t::HV_SUCCESS as i32 { + Ok(()) + } else { + Err(HvfError::Hv(ret as u32)) + } +} + +/// General-purpose register selectors indexed by X-register number (X0..X30). +const X_REGS: [hv_reg_t; 31] = [ + hv_reg_t::X0, + hv_reg_t::X1, + hv_reg_t::X2, + hv_reg_t::X3, + hv_reg_t::X4, + hv_reg_t::X5, + hv_reg_t::X6, + hv_reg_t::X7, + hv_reg_t::X8, + hv_reg_t::X9, + hv_reg_t::X10, + hv_reg_t::X11, + hv_reg_t::X12, + hv_reg_t::X13, + hv_reg_t::X14, + hv_reg_t::X15, + hv_reg_t::X16, + hv_reg_t::X17, + hv_reg_t::X18, + hv_reg_t::X19, + hv_reg_t::X20, + hv_reg_t::X21, + hv_reg_t::X22, + hv_reg_t::X23, + hv_reg_t::X24, + hv_reg_t::X25, + hv_reg_t::X26, + hv_reg_t::X27, + hv_reg_t::X28, + hv_reg_t::X29, + hv_reg_t::X30, +]; + +/// SIMD/FP register selectors indexed by Q-register number (Q0..Q31). +const Q_REGS: [hv_simd_fp_reg_t; 32] = [ + hv_simd_fp_reg_t::Q0, + hv_simd_fp_reg_t::Q1, + hv_simd_fp_reg_t::Q2, + hv_simd_fp_reg_t::Q3, + hv_simd_fp_reg_t::Q4, + hv_simd_fp_reg_t::Q5, + hv_simd_fp_reg_t::Q6, + hv_simd_fp_reg_t::Q7, + hv_simd_fp_reg_t::Q8, + hv_simd_fp_reg_t::Q9, + hv_simd_fp_reg_t::Q10, + hv_simd_fp_reg_t::Q11, + hv_simd_fp_reg_t::Q12, + hv_simd_fp_reg_t::Q13, + hv_simd_fp_reg_t::Q14, + hv_simd_fp_reg_t::Q15, + hv_simd_fp_reg_t::Q16, + hv_simd_fp_reg_t::Q17, + hv_simd_fp_reg_t::Q18, + hv_simd_fp_reg_t::Q19, + hv_simd_fp_reg_t::Q20, + hv_simd_fp_reg_t::Q21, + hv_simd_fp_reg_t::Q22, + hv_simd_fp_reg_t::Q23, + hv_simd_fp_reg_t::Q24, + hv_simd_fp_reg_t::Q25, + hv_simd_fp_reg_t::Q26, + hv_simd_fp_reg_t::Q27, + hv_simd_fp_reg_t::Q28, + hv_simd_fp_reg_t::Q29, + hv_simd_fp_reg_t::Q30, + hv_simd_fp_reg_t::Q31, +]; + +/// General-purpose register state (EL1t guest: `sp` is SP_EL0). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Regs { + /// X0..X30 (X29=FP, X30=LR) + pub x: [u64; 31], + /// SP_EL0 (the active stack pointer at EL1t) + pub sp: u64, + /// Program counter + pub pc: u64, + /// CPSR + pub pstate: u64, +} + +/// SIMD/FP register state. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct FpuState { + /// Q0..Q31 + pub v: [u128; 32], + /// FPSR + pub fpsr: u32, + /// FPCR + pub fpcr: u32, +} + +/// System register state needed by Hyperlight guests. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Sregs { + /// TTBR0_EL1 + pub ttbr0_el1: u64, + /// TCR_EL1 + pub tcr_el1: u64, + /// MAIR_EL1 + pub mair_el1: u64, + /// SCTLR_EL1 + pub sctlr_el1: u64, + /// CPACR_EL1 + pub cpacr_el1: u64, + /// VBAR_EL1 + pub vbar_el1: u64, + /// SP_EL1 + pub sp_el1: u64, +} + +/// Memory permissions for a guest mapping. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Perms { + /// Guest may read + pub read: bool, + /// Guest may write + pub write: bool, + /// Guest may execute + pub exec: bool, +} + +impl Perms { + fn to_hv_flags(self) -> u64 { + let mut flags = 0; + if self.read { + flags |= HV_MEMORY_READ; + } + if self.write { + flags |= HV_MEMORY_WRITE; + } + if self.exec { + flags |= HV_MEMORY_EXEC; + } + flags + } +} + +/// The reasons a vCPU run can exit, decoded from HVF exit info. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum VmExit { + /// The guest wrote to the halt "port" of the IO page + Halt, + /// The guest wrote `data` to the given IO-page port + IoOut(u16, Vec), + /// The guest read from the given unmapped GPA + MmioRead(u64), + /// The guest wrote to the given unmapped GPA + MmioWrite(u64), + /// The run was cancelled via [`cancel`] + Cancelled, + /// Any other exit + Unknown(String), +} + +/// Create the process-wide VM, requesting the 4 KiB IPA granule so that +/// 4 KiB-aligned guest regions can be mapped (the granule API requires +/// macOS 26; the default granule is 16 KiB). Falls back to the default +/// configuration if the config path fails for any reason. +fn create_vm() -> Result<(), HvfError> { + // SAFETY: `hv_vm_config_create` returns a retained config object (or + // null), which is released below. + let config = unsafe { hv_vm_config_create() }; + if !config.is_null() { + // SAFETY: `config` is a live VM config object. + let ret = unsafe { + let ret = hv_vm_config_set_ipa_granule(config, hv_ipa_granule_t::HV_IPA_GRANULE_4KB); + if ret == hv_error_t::HV_SUCCESS as i32 { + hv_vm_create(config) + } else { + ret + } + }; + // SAFETY: `config` is no longer needed. + unsafe { os_release(config) }; + if ret == hv_error_t::HV_SUCCESS as i32 { + return Ok(()); + } + } + // SAFETY: a null config creates a VM with the default configuration. + check_hv(unsafe { hv_vm_create(std::ptr::null_mut()) }) +} + +/// Return `true` if Hypervisor.framework is available in this process. +/// +/// Requires the calling binary to hold the `com.apple.security.hypervisor` +/// entitlement. Note that HVF allows only one live VM per process. +pub fn is_hypervisor_present() -> bool { + if create_vm().is_err() { + return false; + } + // SAFETY: the VM was just created above. + unsafe { hv_vm_destroy() == hv_error_t::HV_SUCCESS as i32 } +} + +/// Force the given vCPU out of `hv_vcpu_run`. +/// +/// Safe to call from any thread; a stale cancel of a destroyed vCPU fails +/// harmlessly since vCPU IDs are kernel-validated. +pub fn cancel(vcpu: hv_vcpu_t) -> Result<(), HvfError> { + // SAFETY: `hv_vcpus_exit` may be called from any thread. + check_hv(unsafe { hv_vcpus_exit(&vcpu, 1) }) +} + +/// A single-vCPU HVF virtual machine. +/// +/// HVF has no slot concept for guest memory; `mappings` tracks the +/// `(gpa, size)` of each slot given to [`Vm::map_memory`] so that slots can +/// be unmapped and replaced. +pub struct Vm { + vcpu: hv_vcpu_t, + /// Exit info written by `hv_vcpu_run` on the vCPU thread. + exit: *const hv_vcpu_exit_t, + /// Slot -> (guest physical address, size) of active mappings. + mappings: HashMap, +} + +// SAFETY: `Vm` is `Send` so it can be moved as part of a sandbox, but all +// vCPU operations (register access and `hv_vcpu_run`) must happen on the +// thread that created the vCPU, and `*const hv_vcpu_exit_t` is only +// dereferenced there. +unsafe impl Send for Vm {} + +impl std::fmt::Debug for Vm { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Vm") + .field("vcpu", &self.vcpu) + .field("mappings", &self.mappings) + .finish() + } +} + +impl Vm { + /// Create the process-wide VM and one vCPU on the current thread. + /// + /// Fails with `HV_BUSY` if a VM already exists in this process (HVF + /// allows only one). + pub fn new() -> Result { + create_vm()?; + + let mut vcpu: hv_vcpu_t = 0; + let mut exit: *const hv_vcpu_exit_t = std::ptr::null(); + // SAFETY: `vcpu` and `exit` are valid out-pointers; a null config + // creates a vCPU with the default configuration on the current thread. + if let Err(e) = check_hv(unsafe { hv_vcpu_create(&mut vcpu, &mut exit, std::ptr::null_mut()) }) + { + // Don't leak the process-wide VM on failure. + // SAFETY: the VM was just created above and has no vCPUs. + unsafe { + let _ = hv_vm_destroy(); + } + return Err(e); + } + + Ok(Self { + vcpu, + exit, + mappings: HashMap::new(), + }) + } + + /// The raw HVF vCPU ID (usable with [`cancel`] from any thread). + pub fn vcpu_id(&self) -> u64 { + self.vcpu + } + + /// Map `size` bytes of host memory at `va` into the guest at `gpa`, + /// replacing any existing mapping for `slot`. + /// + /// # Safety + /// The caller must ensure `[va, va + size)` is valid memory that outlives + /// the mapping. + pub unsafe fn map_memory( + &mut self, + slot: u32, + gpa: u64, + va: usize, + size: usize, + perms: Perms, + ) -> Result<(), HvfError> { + if let Some((old_gpa, old_size)) = self.mappings.remove(&slot) { + // SAFETY: the range was mapped by this VM. + check_hv(unsafe { hv_vm_unmap(old_gpa, old_size) })?; + } + // SAFETY: upheld by the caller (see above). + check_hv(unsafe { hv_vm_map(va as *const c_void, gpa, size, perms.to_hv_flags()) })?; + self.mappings.insert(slot, (gpa, size)); + Ok(()) + } + + /// Unmap the mapping previously created for `slot`. `gpa`/`size` are used + /// as a fallback when the slot is unknown. + pub fn unmap_memory(&mut self, slot: u32, gpa: u64, size: usize) -> Result<(), HvfError> { + let (gpa, size) = self.mappings.remove(&slot).unwrap_or((gpa, size)); + // SAFETY: the range was mapped by this VM. + check_hv(unsafe { hv_vm_unmap(gpa, size) }) + } + + /// Read a general-purpose register by X-register index (0..=30). + fn get_x_reg(&self, index: u8) -> Result { + let mut value = 0; + // SAFETY: called on the vCPU thread with a valid out-pointer. + check_hv(unsafe { hv_vcpu_get_reg(self.vcpu, X_REGS[index as usize], &mut value) })?; + Ok(value) + } + + /// Advance the guest program counter past an instruction of `len` bytes. + fn advance_pc(&self, len: u64) -> Result<(), HvfError> { + let mut pc = 0; + // SAFETY: called on the vCPU thread with a valid out-pointer. + check_hv(unsafe { hv_vcpu_get_reg(self.vcpu, hv_reg_t::PC, &mut pc) })?; + // SAFETY: called on the vCPU thread. + check_hv(unsafe { hv_vcpu_set_reg(self.vcpu, hv_reg_t::PC, pc + len) }) + } + + /// Run the vCPU until it exits, decoding HVF exit info into [`VmExit`]. + pub fn run_vcpu(&mut self) -> Result { + loop { + // SAFETY: called on the vCPU thread; `self.exit` is written by + // the hypervisor before `hv_vcpu_run` returns. + check_hv(unsafe { hv_vcpu_run(self.vcpu) })?; + let exit = unsafe { &*self.exit }; + + match exit.reason { + hv_exit_reason_t::CANCELED => return Ok(VmExit::Cancelled), + hv_exit_reason_t::VTIMER_ACTIVATED => { + // The vtimer is masked automatically on this exit and + // Hyperlight guests do not use interrupts; keep running. + continue; + } + hv_exit_reason_t::UNKNOWN => { + return Ok(VmExit::Unknown("HVF exit reason UNKNOWN".to_string())); + } + hv_exit_reason_t::EXCEPTION => { + let esr = exit.exception.syndrome; + let ipa = exit.exception.physical_address; + match (esr >> 26) & 0x3f { + EC_WFI_WFE => { + // No interrupts are configured; treat WFI/WFE as a + // no-op and keep running. + self.advance_pc(4)?; + continue; + } + EC_INST_ABORT => return Ok(VmExit::MmioRead(ipa)), + EC_DATA_ABORT => { + let io_page_gpa = + const { hyperlight_common::layout::io_page().unwrap().0 }; + let off = ipa.wrapping_sub(io_page_gpa); + let is_io_page = (off as usize) < hyperlight_common::vmem::PAGE_SIZE; + let is_write = esr & ESR_WNR != 0; + + if is_io_page && is_write { + if esr & ESR_ISV == 0 { + // No instruction syndrome: we cannot tell + // which register holds the written data. + return Err(HvfError::NoInstructionSyndrome); + } + let len = 1usize << ((esr >> ESR_SAS_SHIFT) & 0x3); + let srt = ((esr >> ESR_SRT_SHIFT) & 0x1f) as u8; + let value = if srt == 31 { + // XZR as the source register + 0 + } else { + let mask = u64::MAX >> (64 - (len as u64 * 8)); + self.get_x_reg(srt)? & mask + }; + // HVF traps before the access completes; + // skip the store instruction ourselves. + self.advance_pc(4)?; + + let port = (off as usize) / core::mem::size_of::(); + if port == VmAction::Halt as usize { + return Ok(VmExit::Halt); + } + return Ok(VmExit::IoOut( + port as u16, + value.to_le_bytes()[..len].to_vec(), + )); + } + + if is_write { + return Ok(VmExit::MmioWrite(ipa)); + } + return Ok(VmExit::MmioRead(ipa)); + } + _ => { + return Ok(VmExit::Unknown(format!( + "HVF exception exit: syndrome={:#x} va={:#x} ipa={:#x}", + esr, exit.exception.virtual_address, ipa + ))); + } + } + } + } + } + } + + /// Read the general-purpose registers. + pub fn regs(&self) -> Result { + let mut x: [u64; 31] = [0; 31]; + for (i, xi) in x.iter_mut().enumerate() { + *xi = self.get_x_reg(i as u8)?; + } + // SAFETY: called on the vCPU thread with valid out-pointers. + let mut sp = 0; + check_hv(unsafe { hv_vcpu_get_sys_reg(self.vcpu, hv_sys_reg_t::SP_EL0, &mut sp) })?; + let mut pc = 0; + check_hv(unsafe { hv_vcpu_get_reg(self.vcpu, hv_reg_t::PC, &mut pc) })?; + let mut pstate = 0; + check_hv(unsafe { hv_vcpu_get_reg(self.vcpu, hv_reg_t::CPSR, &mut pstate) })?; + Ok(Regs { + x, + sp, + pc, + pstate, + }) + } + + /// Write the general-purpose registers. + pub fn set_regs(&self, regs: &Regs) -> Result<(), HvfError> { + for (i, &value) in regs.x.iter().enumerate() { + // SAFETY: called on the vCPU thread. + check_hv(unsafe { hv_vcpu_set_reg(self.vcpu, X_REGS[i], value) })?; + } + // SAFETY: called on the vCPU thread. + check_hv(unsafe { hv_vcpu_set_sys_reg(self.vcpu, hv_sys_reg_t::SP_EL0, regs.sp) })?; + check_hv(unsafe { hv_vcpu_set_reg(self.vcpu, hv_reg_t::PC, regs.pc) })?; + check_hv(unsafe { hv_vcpu_set_reg(self.vcpu, hv_reg_t::CPSR, regs.pstate) })?; + Ok(()) + } + + /// Read the SIMD/FP registers. + pub fn fpu(&self) -> Result { + let mut v: [u128; 32] = [0; 32]; + for (i, vi) in v.iter_mut().enumerate() { + // SAFETY: called on the vCPU thread with a valid out-pointer. + check_hv(unsafe { hv_vcpu_get_simd_fp_reg(self.vcpu, Q_REGS[i], vi) })?; + } + let mut fpsr = 0; + // SAFETY: called on the vCPU thread with valid out-pointers. + check_hv(unsafe { hv_vcpu_get_reg(self.vcpu, hv_reg_t::FPSR, &mut fpsr) })?; + let mut fpcr = 0; + check_hv(unsafe { hv_vcpu_get_reg(self.vcpu, hv_reg_t::FPCR, &mut fpcr) })?; + Ok(FpuState { + v, + fpsr: fpsr as u32, + fpcr: fpcr as u32, + }) + } + + /// Write the SIMD/FP registers. + pub fn set_fpu(&self, fpu: &FpuState) -> Result<(), HvfError> { + for (i, &value) in fpu.v.iter().enumerate() { + // SAFETY: called on the vCPU thread. + check_hv(unsafe { hv_vcpu_set_simd_fp_reg(self.vcpu, Q_REGS[i], value) })?; + } + // SAFETY: called on the vCPU thread. + check_hv(unsafe { hv_vcpu_set_reg(self.vcpu, hv_reg_t::FPSR, fpu.fpsr as u64) })?; + check_hv(unsafe { hv_vcpu_set_reg(self.vcpu, hv_reg_t::FPCR, fpu.fpcr as u64) })?; + Ok(()) + } + + /// Read the system registers. + pub fn sregs(&self) -> Result { + // SAFETY: called on the vCPU thread with a valid out-pointer. + let get = |reg: hv_sys_reg_t| { + let mut value = 0; + check_hv(unsafe { hv_vcpu_get_sys_reg(self.vcpu, reg, &mut value) })?; + Ok(value) + }; + Ok(Sregs { + ttbr0_el1: get(hv_sys_reg_t::TTBR0_EL1)?, + tcr_el1: get(hv_sys_reg_t::TCR_EL1)?, + mair_el1: get(hv_sys_reg_t::MAIR_EL1)?, + sctlr_el1: get(hv_sys_reg_t::SCTLR_EL1)?, + cpacr_el1: get(hv_sys_reg_t::CPACR_EL1)?, + vbar_el1: get(hv_sys_reg_t::VBAR_EL1)?, + sp_el1: get(hv_sys_reg_t::SP_EL1)?, + }) + } + + /// Write the system registers. + pub fn set_sregs(&self, sregs: &Sregs) -> Result<(), HvfError> { + // SAFETY: called on the vCPU thread. + let set = |reg: hv_sys_reg_t, value: u64| { + check_hv(unsafe { hv_vcpu_set_sys_reg(self.vcpu, reg, value) }) + }; + set(hv_sys_reg_t::TTBR0_EL1, sregs.ttbr0_el1)?; + set(hv_sys_reg_t::TCR_EL1, sregs.tcr_el1)?; + set(hv_sys_reg_t::MAIR_EL1, sregs.mair_el1)?; + set(hv_sys_reg_t::SCTLR_EL1, sregs.sctlr_el1)?; + set(hv_sys_reg_t::CPACR_EL1, sregs.cpacr_el1)?; + set(hv_sys_reg_t::VBAR_EL1, sregs.vbar_el1)?; + set(hv_sys_reg_t::SP_EL1, sregs.sp_el1)?; + Ok(()) + } +} + +impl Drop for Vm { + fn drop(&mut self) { + // SAFETY: `hv_vcpu_destroy` must be called on the thread that created + // the vCPU (see module-level threading note). Errors are ignored: the + // VM and vCPU are per-process resources reclaimed at process exit. + unsafe { + let _ = hv_vcpu_destroy(self.vcpu); + let _ = hv_vm_destroy(); + } + } +} diff --git a/src/hyperlight_hvf/src/lib.rs b/src/hyperlight_hvf/src/lib.rs new file mode 100644 index 000000000..5e21c2ce1 --- /dev/null +++ b/src/hyperlight_hvf/src/lib.rs @@ -0,0 +1,32 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! Shared Hypervisor.framework (HVF) support for macOS on aarch64. +//! +//! - [`core`]: a thin, safe-ish wrapper over the HVF C API (VM/vCPU lifecycle, +//! register access, vCPU run loop with exit decoding). Used both by the +//! direct `hyperlight-host` backend and by the surrogate server. +//! - [`proto`]: the IPC protocol between `hyperlight-host` (client) and the +//! `hvf_surrogate` process (server) that owns a VM when multiple sandboxes +//! must coexist in one host process (HVF allows only one VM per process). +//! +//! Everything in this crate is only meaningful on `aarch64-apple-darwin`; +//! on all other targets the crate compiles empty. + +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +pub mod core; +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +pub mod proto; diff --git a/src/hyperlight_hvf/src/proto.rs b/src/hyperlight_hvf/src/proto.rs new file mode 100644 index 000000000..56b8e7fe2 --- /dev/null +++ b/src/hyperlight_hvf/src/proto.rs @@ -0,0 +1,211 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! IPC protocol between `hyperlight-host` (client) and the `hvf_surrogate` +//! process (server) that owns an HVF VM on behalf of one sandbox. +//! +//! Transport: a `SOCK_STREAM` unix socket pair inherited by the surrogate. +//! Frames are `u32` little-endian length-prefixed JSON bodies. Requests and +//! responses are strictly serialized with one exception: [`Request::Cancel`] +//! may be sent from another thread while a [`Request::RunVcpu`] is +//! outstanding, and receives no response (the pending `RunVcpu` answers with +//! [`Response::Exit`]`(`[`VmExit::Cancelled`]`)`). +//! +//! Guest memory is shared by reference, never copied: anonymous regions are +//! POSIX shm objects (`shm_open`) and file regions are filesystem paths; the +//! surrogate maps the same underlying object into its own address space and +//! then into the guest. + +use std::io::{self, Read, Write}; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize, de::DeserializeOwned}; + +use crate::core::{FpuState, Perms, Regs, Sregs, VmExit}; + +/// Current protocol version, exchanged in the [`Request::Hello`] handshake. +pub const PROTO_VERSION: u32 = 1; + +/// Largest frame we are willing to read (16 MiB; real frames are a few KiB). +const MAX_FRAME_SIZE: u32 = 16 * 1024 * 1024; + +/// How the surrogate can access the memory object backing a guest region. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Backing { + /// A POSIX shm object (created with `shm_open`), with the byte offset of + /// the usable region within the object. + Shm { + /// Object name as passed to `shm_open` (e.g. `/hl-`) + name: String, + /// Offset of the region start within the object + offset: u64, + }, + /// A filesystem path (used for read-only file mappings), with the byte + /// offset of the region within the file. + File { + /// Path to the file + path: PathBuf, + /// Offset of the region start within the file + offset: u64, + }, +} + +/// A request from the host to the surrogate. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Request { + /// Protocol handshake; must be the first message on a connection. + Hello { + /// Client protocol version ([`PROTO_VERSION`]) + version: u32, + }, + /// Create the VM and vCPU. Must precede any other VM operation; the VM + /// stays alive until [`Request::DestroyVm`] (or process exit). + CreateVm, + /// Destroy the VM and vCPU, returning the surrogate to its idle state so + /// the process can be reused by another sandbox. + DestroyVm, + /// Map `size` bytes of `backing` into the guest at `gpa` for `slot`, + /// replacing any existing mapping for that slot. + MapMemory { + /// Mapping slot identifier + slot: u32, + /// Guest physical address + gpa: u64, + /// Region size in bytes + size: u64, + /// Guest permissions + perms: Perms, + /// The memory object backing the region + backing: Backing, + }, + /// Unmap the region previously mapped for `slot` (`gpa`/`size` are a + /// fallback when the slot is unknown to the server). + UnmapMemory { + /// Mapping slot identifier + slot: u32, + /// Guest physical address + gpa: u64, + /// Region size in bytes + size: u64, + }, + /// Run the vCPU until it exits. Responds with [`Response::Exit`]. + RunVcpu, + /// Read the general-purpose registers. Responds with [`Response::Regs`]. + GetRegs, + /// Write the general-purpose registers. Responds with [`Response::Ok`]. + SetRegs(Regs), + /// Read the SIMD/FP registers. Responds with [`Response::Fpu`]. + GetFpu, + /// Write the SIMD/FP registers. Responds with [`Response::Ok`]. + SetFpu(FpuState), + /// Read the system registers. Responds with [`Response::Sregs`]. + GetSregs, + /// Write the system registers. Responds with [`Response::Ok`]. + SetSregs(Sregs), + /// Force the running vCPU out of `hv_vcpu_run`. May be sent while a + /// [`Request::RunVcpu`] is outstanding; receives no response. + Cancel, +} + +/// A response from the surrogate to the host. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Response { + /// Protocol handshake answer. + Hello { + /// Server protocol version ([`PROTO_VERSION`]) + version: u32, + }, + /// The request succeeded with no payload. + Ok, + /// Answer to [`Request::GetRegs`]. + Regs(Regs), + /// Answer to [`Request::GetFpu`]. + Fpu(FpuState), + /// Answer to [`Request::GetSregs`]. + Sregs(Sregs), + /// Answer to [`Request::RunVcpu`]. + Exit(VmExit), + /// The request failed. + Err(String), +} + +/// Write one length-prefixed frame. +pub fn write_frame(w: &mut impl Write, msg: &T) -> io::Result<()> { + let body = serde_json::to_vec(msg).map_err(io::Error::other)?; + let len: u32 = body + .len() + .try_into() + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "frame too large"))?; + w.write_all(&len.to_le_bytes())?; + w.write_all(&body)?; + w.flush() +} + +/// Read one length-prefixed frame. Returns `Ok(None)` on a clean EOF at a +/// frame boundary (peer closed the connection). +pub fn read_frame(r: &mut impl Read) -> io::Result> { + let mut len_buf = [0u8; 4]; + match r.read_exact(&mut len_buf) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None), + Err(e) => return Err(e), + } + let len = u32::from_le_bytes(len_buf); + if len > MAX_FRAME_SIZE { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("frame length {len} exceeds maximum"), + )); + } + let mut body = vec![0u8; len as usize]; + r.read_exact(&mut body)?; + let msg = serde_json::from_slice(&body).map_err(io::Error::other)?; + Ok(Some(msg)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_round_trip() { + let mut buf = Vec::new(); + let req = Request::MapMemory { + slot: 3, + gpa: 0x1000, + size: 0x4000, + perms: Perms { + read: true, + write: true, + exec: false, + }, + backing: Backing::Shm { + name: "/hl-test".to_string(), + offset: 0x4000, + }, + }; + write_frame(&mut buf, &req).unwrap(); + let decoded: Request = read_frame(&mut &buf[..]).unwrap().unwrap(); + assert_eq!(format!("{decoded:?}"), format!("{req:?}")); + } + + #[test] + fn clean_eof_is_none() { + let mut empty: &[u8] = &[]; + let res: Option = read_frame(&mut empty).unwrap(); + assert!(res.is_none()); + } +} From 35903c9b7024825eca6697034ea0eb9c19038a0a Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Wed, 22 Jul 2026 15:59:08 +0100 Subject: [PATCH 02/14] build(host): restrict kvm/mshv deps to linux, add hvf feature - Move kvm-bindings/kvm-ioctls/mshv-bindings/mshv-ioctls from cfg(unix) to cfg(linux) target dependencies; kvm-ioctls does not compile on macOS even though the code is feature-gated off. - Add an optional hvf cargo feature (applevisor-sys + hyperlight-hvf) and an hvf cfg alias: all(feature = "hvf", target_os = "macos", target_arch = "aarch64"). Signed-off-by: Sienna Meridian Satterwhite --- src/hyperlight_host/Cargo.toml | 7 ++++++- src/hyperlight_host/build.rs | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/hyperlight_host/Cargo.toml b/src/hyperlight_host/Cargo.toml index 90f862d69..d03053636 100644 --- a/src/hyperlight_host/Cargo.toml +++ b/src/hyperlight_host/Cargo.toml @@ -78,12 +78,16 @@ rust-embed = { version = "8.11.0", features = ["debug-embed", "include-exclude", windows-version = "0.1" lazy_static = "1.4.0" -[target.'cfg(unix)'.dependencies] +[target.'cfg(target_os = "linux")'.dependencies] kvm-bindings = { version = "0.14", features = ["fam-wrappers"], optional = true } kvm-ioctls = { version = "0.25", optional = true } mshv-bindings = { version = "0.6", optional = true } mshv-ioctls = { version = "0.6", optional = true} +[target.'cfg(all(target_os = "macos", target_arch = "aarch64"))'.dependencies] +applevisor-sys = { version = "1.0", optional = true } +hyperlight-hvf = { workspace = true, optional = true } + [dev-dependencies] uuid = { version = "1.23.3", features = ["v4"] } signal-hook-registry = "1.4.8" @@ -136,6 +140,7 @@ trace_guest = ["dep:opentelemetry", "dep:tracing-opentelemetry", "dep:hyperlight mem_profile = [ "trace_guest", "dep:framehop", "dep:fallible-iterator", "hyperlight-common/mem_profile" ] kvm = ["dep:kvm-bindings", "dep:kvm-ioctls"] mshv3 = ["dep:mshv-bindings", "dep:mshv-ioctls"] +hvf = ["dep:applevisor-sys", "dep:hyperlight-hvf"] hw-interrupts = [] # This enables easy debug in the guest gdb = ["dep:gdbstub", "dep:gdbstub_arch"] diff --git a/src/hyperlight_host/build.rs b/src/hyperlight_host/build.rs index 57a06e4bb..c52b7c598 100644 --- a/src/hyperlight_host/build.rs +++ b/src/hyperlight_host/build.rs @@ -108,6 +108,7 @@ fn main() -> Result<()> { gdb: { all(feature = "gdb", debug_assertions, target_arch = "x86_64") }, kvm: { all(feature = "kvm", target_os = "linux") }, mshv3: { all(feature = "mshv3", target_os = "linux") }, + hvf: { all(feature = "hvf", target_os = "macos", target_arch = "aarch64") }, crashdump: { all(feature = "crashdump", target_arch = "x86_64") }, // print_debug feature is aliased with debug_assertions to make it only available in debug-builds. print_debug: { all(feature = "print_debug", debug_assertions) }, From 91965c6fae0700e6db220bb1681350498c1af01c Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Wed, 22 Jul 2026 15:59:21 +0100 Subject: [PATCH 03/14] feat(host): support macOS guest memory in shared_mem Three related changes to make the host memory layer work on macOS: - Widen POSIX implementations (mmap/mprotect paths, PreparedFileMapping, interrupt config getters) from cfg(linux) to cfg(unix). - Size guard pages to the host page size (16 KiB on Apple silicon) instead of the 4 KiB guest page granule; mprotect requires host-page-aligned ranges. Also fixes the same latent issue on 16K/64K-page aarch64 Linux kernels. - Back all guest memory by named POSIX shm objects on macOS and carry the backing metadata (object name, offset) through a macOS HostRegionBase, mirroring the Windows type-level split. This allows a surrogate process to map the same memory into a per-sandbox HVF VM (HVF allows only one VM per process). Signed-off-by: Sienna Meridian Satterwhite --- src/hyperlight_host/src/mem/layout.rs | 8 +- src/hyperlight_host/src/mem/memory_region.rs | 74 ++- src/hyperlight_host/src/mem/shared_mem.rs | 452 ++++++++++++++++-- src/hyperlight_host/src/sandbox/config.rs | 6 +- .../src/sandbox/file_mapping.rs | 52 +- 5 files changed, 520 insertions(+), 72 deletions(-) diff --git a/src/hyperlight_host/src/mem/layout.rs b/src/hyperlight_host/src/mem/layout.rs index 6422b9b11..561f53212 100644 --- a/src/hyperlight_host/src/mem/layout.rs +++ b/src/hyperlight_host/src/mem/layout.rs @@ -120,10 +120,10 @@ impl<'a> BaseGpaRegion<&'a [u8], &'a [u8]> { BaseGpaRegion::Snapshot(sn) => sn, BaseGpaRegion::Scratch(sc) => sc, BaseGpaRegion::Mmap(r) => unsafe { - #[allow(clippy::useless_conversion)] - let host_region_base: usize = r.host_region.start.into(); - #[allow(clippy::useless_conversion)] - let host_region_end: usize = r.host_region.end.into(); + #[allow(clippy::useless_conversion, clippy::clone_on_copy)] + let host_region_base: usize = r.host_region.start.clone().into(); + #[allow(clippy::useless_conversion, clippy::clone_on_copy)] + let host_region_end: usize = r.host_region.end.clone().into(); let len = host_region_end - host_region_base; std::slice::from_raw_parts(host_region_base as *const u8, len) }, diff --git a/src/hyperlight_host/src/mem/memory_region.rs b/src/hyperlight_host/src/mem/memory_region.rs index 5d839647a..fb086cd1b 100644 --- a/src/hyperlight_host/src/mem/memory_region.rs +++ b/src/hyperlight_host/src/mem/memory_region.rs @@ -171,7 +171,10 @@ impl MemoryRegionType { /// This trait is used to parameterize [`MemoryRegion_`] pub trait MemoryRegionKind { /// The type used to represent host memory addresses. - type HostBaseType: Copy; + /// + /// This is only required to be [`Clone`] (not [`Copy`]) because the + /// macOS variant carries the name of the backing POSIX shm object. + type HostBaseType: Clone; /// Computes an address by adding a size to a base address. /// @@ -196,7 +199,7 @@ pub trait MemoryRegionKind { #[derive(Debug, PartialEq, Eq, Copy, Clone, Hash)] pub struct HostGuestMemoryRegion {} -#[cfg(not(target_os = "windows"))] +#[cfg(not(any(target_os = "windows", target_os = "macos")))] impl MemoryRegionKind for HostGuestMemoryRegion { type HostBaseType = usize; @@ -276,6 +279,44 @@ impl MemoryRegionKind for HostGuestMemoryRegion { } } +/// A [`HostRegionBase`] keeps track of not just a host virtual address, +/// but also the POSIX shm object backing it. This is used on macOS, +/// where HVF allows only one VM per process: each sandbox's VM may live +/// in a surrogate helper process that maps the same memory by +/// `shm_open`-ing `name` and mapping it at `offset`. +/// +/// The shm object spans the guard pages surrounding the usable region; +/// `offset` is relative to the start of the object (i.e. it includes +/// the leading guard page). +#[cfg(target_os = "macos")] +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +pub struct HostRegionBase { + /// Name of the backing POSIX shm object, as passed to `shm_open` + pub name: String, + /// Offset of the region start within the shm object + pub offset: usize, + /// Host virtual address of the region start + pub base: usize, +} +#[cfg(target_os = "macos")] +impl From for usize { + fn from(x: HostRegionBase) -> usize { + x.base + } +} +#[cfg(target_os = "macos")] +impl MemoryRegionKind for HostGuestMemoryRegion { + type HostBaseType = HostRegionBase; + + fn add(base: Self::HostBaseType, size: usize) -> Self::HostBaseType { + HostRegionBase { + name: base.name, + offset: base.offset + size, + base: base.base + size, + } + } +} + /// Type for memory regions that only track guest addresses. /// #[derive(Debug, PartialEq, Eq, Copy, Clone, Hash)] @@ -306,6 +347,27 @@ pub struct MemoryRegion_ { /// A memory region that tracks both host and guest addresses. pub type MemoryRegion = MemoryRegion_; +#[cfg(hvf)] +impl MemoryRegion { + /// Extract the surrogate-IPC view of this region: the shm backing + /// (object name + offset of the region within the object), the + /// region size, and its guest physical address. + /// + /// Used when delegating the mapping to the HVF surrogate process; + /// see [`hyperlight_hvf::proto::Request::MapMemory`]. + #[allow(dead_code)] // consumed by the surrogate-process backend (a later phase) + pub(crate) fn surrogate_backing(&self) -> (hyperlight_hvf::proto::Backing, u64, u64) { + ( + hyperlight_hvf::proto::Backing::Shm { + name: self.host_region.start.name.clone(), + offset: self.host_region.start.offset as u64, + }, + (self.guest_region.end - self.guest_region.start) as u64, + self.guest_region.start as u64, + ) + } +} + /// A [`MemoryRegionKind`] for crash dump regions that always uses raw /// `usize` host addresses. The crash dump path only reads host memory /// through raw pointers, so it never needs the file-mapping metadata @@ -353,10 +415,10 @@ impl MemoryRegionVecBuilder { ) -> usize { if self.regions.is_empty() { let guest_end = self.guest_base_phys_addr + size; - let host_end = ::add(self.host_base_virt_addr, size); + let host_end = ::add(self.host_base_virt_addr.clone(), size); self.regions.push(MemoryRegion_ { guest_region: self.guest_base_phys_addr..guest_end, - host_region: self.host_base_virt_addr..host_end, + host_region: self.host_base_virt_addr.clone()..host_end, flags, region_type, }); @@ -366,10 +428,10 @@ impl MemoryRegionVecBuilder { #[allow(clippy::unwrap_used)] // we know this is safe because we check if the regions are empty above let last_region = self.regions.last().unwrap(); - let host_end = ::add(last_region.host_region.end, size); + let host_end = ::add(last_region.host_region.end.clone(), size); let new_region = MemoryRegion_ { guest_region: last_region.guest_region.end..last_region.guest_region.end + size, - host_region: last_region.host_region.end..host_end, + host_region: last_region.host_region.end.clone()..host_end, flags, region_type, }; diff --git a/src/hyperlight_host/src/mem/shared_mem.rs b/src/hyperlight_host/src/mem/shared_mem.rs index 4b706cc41..837844157 100644 --- a/src/hyperlight_host/src/mem/shared_mem.rs +++ b/src/hyperlight_host/src/mem/shared_mem.rs @@ -18,7 +18,7 @@ use std::any::type_name; use std::ffi::c_void; use std::io::Error; use std::mem::{align_of, size_of}; -#[cfg(target_os = "linux")] +#[cfg(unix)] use std::ptr::null_mut; use std::sync::{Arc, RwLock}; @@ -99,6 +99,20 @@ macro_rules! generate_writer { pub struct HostMapping { #[cfg(not(target_os = "windows"))] mmap: Mmap, + /// Number of usable bytes between the guard regions, i.e. the + /// size reported by [`SharedMemory::mem_size`]. This is the + /// originally requested size; the mapped span between the guards + /// may be larger, since it is rounded up to the host page size so + /// that the trailing guard starts on a host-page boundary (see + /// [`guard_page_size`]). + #[cfg(not(target_os = "windows"))] + usable_size: usize, + /// The POSIX shm object backing `mmap`. Kept open (and named) for + /// as long as the mapping exists so a surrogate process can map the + /// same memory by name. Declared after `mmap` so that drop order is + /// `munmap` first, then `close` + `shm_unlink`. + #[cfg(target_os = "macos")] + shm: ShmBacking, #[cfg(target_os = "windows")] mapping: WindowsMapping, } @@ -177,15 +191,96 @@ impl HostMapping { } } +/// RAII guard for a POSIX shm object created with `shm_open`. Closes +/// the fd and unlinks the name on drop. +/// +/// macOS only: all guest memory is backed by named shm objects so that +/// the HVF surrogate process (which owns the VM when multiple sandboxes +/// coexist; HVF allows only one VM per process) can map the same memory +/// by name. +#[cfg(target_os = "macos")] +#[derive(Debug)] +struct ShmBacking { + /// Object name as passed to `shm_open` (e.g. `/hl--`) + name: String, + /// Open descriptor for the object + fd: std::os::fd::RawFd, +} + +#[cfg(target_os = "macos")] +impl ShmBacking { + /// Create a new, uniquely-named shm object of `size` bytes. + /// + /// The name has the form `/hl--`; macOS limits shm + /// names to `PSHMNAMELEN` (31) characters, so keep it short. + fn create(size: usize) -> Result { + use std::ffi::CString; + + use rand::RngExt; + + let pid = std::process::id(); + let mut rng = rand::rng(); + // Retry on the (unlikely) name collision with an existing object. + loop { + let name = format!("/hl-{pid:x}-{:016x}", rng.random::()); + debug_assert!(name.len() <= 31, "shm name too long: {name}"); + let c_name = CString::new(name.as_str()) + .map_err(|e| new_error!("shm name contains NUL byte: {}", e))?; + // SAFETY: `c_name` is a valid NUL-terminated string and the + // flags/mode are valid; on success the returned fd is ours. + let fd = unsafe { + libc::shm_open( + c_name.as_ptr(), + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL, + 0o600, + ) + }; + if fd < 0 { + let err = Error::last_os_error(); + if err.raw_os_error() == Some(libc::EEXIST) { + continue; + } + log_then_return!(HyperlightError::MemoryAllocationFailed(err.raw_os_error())); + } + // SAFETY: `fd` is a valid descriptor for the object we just + // created. On failure, clean up the object before returning. + if unsafe { libc::ftruncate(fd, size as libc::off_t) } != 0 { + let err = Error::last_os_error(); + unsafe { + libc::close(fd); + libc::shm_unlink(c_name.as_ptr()); + } + log_then_return!(HyperlightError::MemoryAllocationFailed(err.raw_os_error())); + } + return Ok(Self { name, fd }); + } + } +} + +#[cfg(target_os = "macos")] +impl Drop for ShmBacking { + fn drop(&mut self) { + // Errors are ignored: the surrogate process may have unlinked + // the name first, and there is nothing useful to do about a + // failed close here anyway. + unsafe { + libc::close(self.fd); + if let Ok(c_name) = std::ffi::CString::new(self.name.as_str()) { + libc::shm_unlink(c_name.as_ptr()); + } + } + } +} + /// RAII guard for an `mmap` reservation. Calls `munmap` on drop. -#[cfg(target_os = "linux")] +#[cfg(unix)] #[derive(Debug)] struct Mmap { base: *mut c_void, len: usize, } -#[cfg(target_os = "linux")] +#[cfg(unix)] impl Drop for Mmap { fn drop(&mut self) { // SAFETY: `self.base` and `self.len` are exactly what was @@ -202,6 +297,102 @@ impl Drop for Mmap { } } +/// Size of each guard region surrounding a shared memory mapping. +/// +/// `mprotect` requires host-page-aligned addresses and lengths, so a +/// guard region must span a whole host page. The host page size can +/// exceed the guest page size ([`PAGE_SIZE_USIZE`]) — e.g. 16 KiB on +/// Apple silicon, or 64 KiB on some aarch64 Linux kernels — so use +/// the larger of the two. +#[cfg(unix)] +fn guard_page_size() -> usize { + page_size::get().max(PAGE_SIZE_USIZE) +} + +/// macOS: create a `[guard][usable][guard]` mapping backed by a named +/// POSIX shm object, so that the HVF surrogate process can map the same +/// memory by name. +/// +/// The layout matches the anonymous-mapping unix path: the shm object +/// (and the mapping) spans both guard pages, the usable region starts +/// one host page ([`guard_page_size`]) into the object, and the span +/// between the guards is rounded up to the host page size. Only +/// `usable_size` bytes are exposed as usable memory (via +/// [`HostMapping::usable_size`]); the padding is mapped read-write but +/// is otherwise invisible. +#[cfg(target_os = "macos")] +fn shm_backed_host_mapping(usable_size: usize) -> Result { + use libc::{MAP_FAILED, MAP_SHARED, PROT_NONE, PROT_READ, PROT_WRITE, mmap, mprotect}; + + let guard_size = guard_page_size(); + let mapped_size = usable_size + .checked_next_multiple_of(guard_size) + .ok_or_else(|| new_error!("Memory required for sandbox exceeded usize::MAX"))?; + let total_size = mapped_size + .checked_add(2 * guard_size) // guard region around the memory + .ok_or_else(|| new_error!("Memory required for sandbox exceeded usize::MAX"))?; + + // usize and isize are guaranteed to be the same size, and + // isize::MAX should be positive, so this cast should be safe. + if total_size > isize::MAX as usize { + return Err(HyperlightError::MemoryRequestTooBig( + total_size, + isize::MAX as usize, + )); + } + + let shm = ShmBacking::create(total_size)?; + + // SAFETY: `shm.fd` is a valid descriptor for a shm object of + // `total_size` bytes, and mapping it in full with `MAP_SHARED` has + // no other preconditions; the kernel picks the address. + let addr = unsafe { + mmap( + null_mut(), + total_size as libc::size_t, + PROT_READ | PROT_WRITE, + MAP_SHARED, + shm.fd, + 0 as libc::off_t, + ) + }; + if addr == MAP_FAILED { + log_then_return!(HyperlightError::MmapFailed( + Error::last_os_error().raw_os_error() + )); + } + let mmap = Mmap { + base: addr, + len: total_size, + }; + + // protect the guard regions (one host page each) + let res = unsafe { mprotect(mmap.base, guard_size, PROT_NONE) }; + if res != 0 { + return Err(HyperlightError::MprotectFailed( + Error::last_os_error().raw_os_error(), + )); + } + let res = unsafe { + mprotect( + (mmap.base as *const u8).add(total_size - guard_size) as *mut c_void, + guard_size, + PROT_NONE, + ) + }; + if res != 0 { + return Err(HyperlightError::MprotectFailed( + Error::last_os_error().raw_os_error(), + )); + } + + Ok(HostMapping { + mmap, + usable_size, + shm, + }) +} + /// RAII guard for a Win32 mapped view. Calls `UnmapViewOfFile` on drop. #[cfg(target_os = "windows")] #[derive(Debug)] @@ -401,7 +592,14 @@ pub trait SharedMemory { /// need to be marked as `unsafe` because doing anything with this /// pointer itself requires `unsafe`. fn base_addr(&self) -> usize { - self.region().ptr() as usize + PAGE_SIZE_USIZE + #[cfg(not(windows))] + { + self.region().ptr() as usize + guard_page_size() + } + #[cfg(windows)] + { + self.region().ptr() as usize + PAGE_SIZE_USIZE + } } /// Return the base address of the host mapping of this region as @@ -409,14 +607,30 @@ pub trait SharedMemory { /// not need to be marked as `unsafe` because doing anything with /// this pointer itself requires `unsafe`. fn base_ptr(&self) -> *mut u8 { - self.region().ptr().wrapping_add(PAGE_SIZE_USIZE) + #[cfg(not(windows))] + { + self.region().ptr().wrapping_add(guard_page_size()) + } + #[cfg(windows)] + { + self.region().ptr().wrapping_add(PAGE_SIZE_USIZE) + } } /// Return the length of usable memory contained in `self`. /// The returned size does not include the size of the surrounding - /// guard pages. + /// guard regions, nor (on unix) any padding inserted to align the + /// trailing guard to the host page size: it is exactly the size + /// requested when the region was created. fn mem_size(&self) -> usize { - self.region().size() - 2 * PAGE_SIZE_USIZE + #[cfg(not(windows))] + { + self.region().usable_size + } + #[cfg(windows)] + { + self.region().size() - 2 * PAGE_SIZE_USIZE + } } /// Return the raw base address of the host mapping, including the @@ -437,11 +651,22 @@ pub trait SharedMemory { /// On Linux this returns a raw `usize` pointer. On Windows it /// returns a [`HostRegionBase`](super::memory_region::HostRegionBase) /// that carries the file-mapping handle metadata needed by WHP. + /// On macOS it returns a [`HostRegionBase`](super::memory_region::HostRegionBase) + /// that carries the shm object name + offset needed by the HVF + /// surrogate process. fn host_region_base(&self) -> ::HostBaseType { - #[cfg(not(windows))] + #[cfg(all(not(windows), not(target_os = "macos")))] { self.base_addr() } + #[cfg(target_os = "macos")] + { + super::memory_region::HostRegionBase { + name: self.region().shm.name.clone(), + offset: guard_page_size(), + base: self.base_addr(), + } + } #[cfg(windows)] { super::memory_region::HostRegionBase { @@ -537,7 +762,7 @@ impl ExclusiveSharedMemory { /// size in bytes. The region will be surrounded by guard pages. /// /// Return `Err` if shared memory could not be allocated. - #[cfg(target_os = "linux")] + #[cfg(all(unix, not(target_os = "macos")))] #[instrument(skip_all, parent = Span::current(), level= "Trace")] pub fn new(min_size_bytes: usize) -> Result { use libc::{ @@ -551,17 +776,32 @@ impl ExclusiveSharedMemory { return Err(new_error!("Cannot create shared memory with size 0")); } - let total_size = min_size_bytes - .checked_add(2 * PAGE_SIZE_USIZE) // guard page around the memory - .ok_or_else(|| new_error!("Memory required for sandbox exceeded usize::MAX"))?; - - if total_size % PAGE_SIZE_USIZE != 0 { + if min_size_bytes % PAGE_SIZE_USIZE != 0 { return Err(new_error!( "shared memory must be a multiple of {}", PAGE_SIZE_USIZE )); } + // The guard regions must each span a whole host page: + // `mprotect` requires host-page-aligned addresses and lengths, + // and the host page size may exceed the guest page size (see + // `guard_page_size`). + let guard_size = guard_page_size(); + + // Round the mapped span between the guards up to a host-page + // multiple so that the trailing guard starts on a host-page + // boundary. Only the requested `min_size_bytes` is exposed as + // usable memory (via `HostMapping::usable_size`); the padding + // is mapped read-write but is otherwise invisible. + let mapped_size = min_size_bytes + .checked_next_multiple_of(guard_size) + .ok_or_else(|| new_error!("Memory required for sandbox exceeded usize::MAX"))?; + + let total_size = mapped_size + .checked_add(2 * guard_size) // guard region around the memory + .ok_or_else(|| new_error!("Memory required for sandbox exceeded usize::MAX"))?; + // usize and isize are guaranteed to be the same size, and // isize::MAX should be positive, so this cast should be safe. if total_size > isize::MAX as usize { @@ -597,10 +837,10 @@ impl ExclusiveSharedMemory { len: total_size, }; - // protect the guard pages + // protect the guard regions (one host page each) #[cfg(not(miri))] { - let res = unsafe { mprotect(mmap.base, PAGE_SIZE_USIZE, PROT_NONE) }; + let res = unsafe { mprotect(mmap.base, guard_size, PROT_NONE) }; if res != 0 { return Err(HyperlightError::MprotectFailed( Error::last_os_error().raw_os_error(), @@ -608,8 +848,8 @@ impl ExclusiveSharedMemory { } let res = unsafe { mprotect( - (mmap.base as *const u8).add(total_size - PAGE_SIZE_USIZE) as *mut c_void, - PAGE_SIZE_USIZE, + (mmap.base as *const u8).add(total_size - guard_size) as *mut c_void, + guard_size, PROT_NONE, ) }; @@ -630,7 +870,46 @@ impl ExclusiveSharedMemory { // type does have Send and Sync manually impl'd, the Arc // is not pointless as the lint suggests. #[allow(clippy::arc_with_non_send_sync)] - region: Arc::new(HostMapping { mmap }), + region: Arc::new(HostMapping { + mmap, + usable_size: min_size_bytes, + }), + }) + } + + /// Create a new region of shared memory with the given minimum + /// size in bytes. The region will be surrounded by guard pages. + /// + /// On macOS the region is backed by a named POSIX shm object (see + /// [`shm_backed_host_mapping`]) so that the HVF surrogate process + /// can map the same memory by name. + /// + /// Return `Err` if shared memory could not be allocated. + #[cfg(target_os = "macos")] + #[instrument(skip_all, parent = Span::current(), level= "Trace")] + pub fn new(min_size_bytes: usize) -> Result { + if min_size_bytes == 0 { + return Err(new_error!("Cannot create shared memory with size 0")); + } + + if min_size_bytes % PAGE_SIZE_USIZE != 0 { + return Err(new_error!( + "shared memory must be a multiple of {}", + PAGE_SIZE_USIZE + )); + } + + Ok(Self { + // HostMapping is only non-Send/Sync because raw pointers + // are not ("as a lint", as the Rust docs say). We don't + // want to mark HostMapping Send/Sync immediately, because + // that could socially imply that it's "safe" to use + // unsafe accesses from multiple threads at once. Instead, we + // directly impl Send and Sync on this type. Since this + // type does have Send and Sync manually impl'd, the Arc + // is not pointless as the lint suggests. + #[allow(clippy::arc_with_non_send_sync)] + region: Arc::new(shm_backed_host_mapping(min_size_bytes)?), }) } @@ -1560,10 +1839,11 @@ impl ReadonlySharedMemory { }) } - /// Linux: reserve `[guard][blob][guard]` as one anonymous + /// Unix: reserve `[guard][blob][guard]` as one anonymous /// `PROT_NONE` mapping, then `MAP_FIXED` the file over the - /// middle slot. - #[cfg(target_os = "linux")] + /// middle slot. Each guard spans one host page (see + /// [`guard_page_size`]). + #[cfg(all(unix, not(target_os = "macos")))] fn map_file(file: &std::fs::File, len: usize) -> Result> { use std::os::unix::io::AsRawFd; @@ -1574,7 +1854,17 @@ impl ReadonlySharedMemory { mmap, off_t, size_t, }; - let total_size = len.checked_add(2 * PAGE_SIZE_USIZE).ok_or_else(|| { + // The guard regions must each span a whole host page (see + // `guard_page_size`), so round the middle slot up to a + // host-page multiple to keep the trailing guard host-page + // aligned. Only `len` bytes are exposed as usable memory; any + // padding between the end of the file and the trailing guard + // keeps the reservation's `PROT_NONE`. + let guard_size = guard_page_size(); + let mapped_size = len.checked_next_multiple_of(guard_size).ok_or_else(|| { + new_error!("Memory required for file-backed mapping exceeded usize::MAX") + })?; + let total_size = mapped_size.checked_add(2 * guard_size).ok_or_else(|| { new_error!("Memory required for file-backed mapping exceeded usize::MAX") })?; @@ -1617,9 +1907,9 @@ impl ReadonlySharedMemory { let file_prot = PROT_READ | PROT_WRITE; #[cfg(not(mshv3))] let file_prot = PROT_READ; - // SAFETY: `total_size = len + 2 * PAGE_SIZE_USIZE`, so - // `base + PAGE_SIZE_USIZE` is in-bounds of the reservation. - let usable_ptr = unsafe { (base as *mut u8).add(PAGE_SIZE_USIZE) }; + // SAFETY: `total_size = mapped_size + 2 * guard_size`, so + // `base + guard_size` is in-bounds of the reservation. + let usable_ptr = unsafe { (base as *mut u8).add(guard_size) }; // SAFETY: `usable_ptr..usable_ptr + len` lies entirely within // the reservation owned by `reservation`. `MAP_FIXED` // replaces that sub-range in place; on failure the @@ -1641,11 +1931,34 @@ impl ReadonlySharedMemory { )); } - // 3. The first and last pages keep their `PROT_NONE` from the - // anonymous reservation, so no extra `mprotect` is needed. + // 3. The first and last host pages keep their `PROT_NONE` from + // the anonymous reservation, so no extra `mprotect` is + // needed. #[allow(clippy::arc_with_non_send_sync)] - Ok(Arc::new(HostMapping { mmap: reservation })) + Ok(Arc::new(HostMapping { + mmap: reservation, + usable_size: len, + })) + } + + /// macOS: read the file contents into a fresh named POSIX shm + /// object with the usual `[guard][blob][guard]` layout (the same + /// layout as the mmap'd file has on other unix platforms). + /// + /// The surrogate process maps guest memory by shm object name — it + /// cannot be handed an fd — so mapping the file directly is not an + /// option; snapshot files are small, so copying is fine. This is + /// exactly the `from_bytes` pattern: allocate shm-backed memory, + /// then copy the contents in. + #[cfg(target_os = "macos")] + fn map_file(file: &std::fs::File, len: usize) -> Result> { + use std::os::unix::fs::FileExt; + + let mut anon = ExclusiveSharedMemory::new(len)?; + file.read_exact_at(anon.as_mut_slice(), 0) + .map_err(|e| new_error!("Failed to read file into shared memory: {}", e))?; + Ok(anon.region) } /// Windows: reserve `[guard][blob][guard]` as one @@ -2001,7 +2314,7 @@ mod tests { /// Test that verifies memory is properly unmapped when all SharedMemory /// references are dropped. #[test] - #[cfg(all(target_os = "linux", not(miri)))] + #[cfg(all(unix, not(miri)))] fn test_drop() { use proc_maps::get_process_maps; @@ -2022,9 +2335,12 @@ mod tests { let (hshm1, gshm) = eshm.build(); let hshm2 = hshm1.clone(); - // Use the usable memory region (not raw), since mprotect splits the mapping + // Use the usable memory region (not raw), since mprotect splits the mapping. + // The span between the guards is rounded up to the host page + // size, which may exceed PAGE_SIZE_USIZE (e.g. 16 KiB on Apple + // silicon); `mem_size()` only reports the requested size. let base_ptr = hshm1.base_ptr() as usize; - let mem_size = hshm1.mem_size(); + let mem_size = hshm1.mem_size().next_multiple_of(super::guard_page_size()); // Helper to check if exact mapping exists (matching both address and size) let has_exact_mapping = |ptr: usize, size: usize| -> bool { @@ -2458,7 +2774,7 @@ mod tests { } } - #[cfg(target_os = "linux")] + #[cfg(unix)] mod guard_page_crash_test { use crate::mem::shared_mem::{ExclusiveSharedMemory, SharedMemory}; @@ -2473,6 +2789,14 @@ mod tests { std::process::exit(TEST_EXIT_CODE.into()); }) .unwrap(); + // macOS reports accesses to PROT_NONE pages as + // EXC_BAD_ACCESS/KERN_PROTECTION_FAILURE, which is + // delivered as SIGBUS rather than SIGSEGV. + #[cfg(target_os = "macos")] + signal_hook_registry::register_signal_unchecked(libc::SIGBUS, || { + std::process::exit(TEST_EXIT_CODE.into()); + }) + .unwrap(); } } @@ -2514,18 +2838,17 @@ mod tests { #[test] #[cfg_attr(miri, ignore)] // miri can't spawn subprocesses fn guard_page_testing_shim() { - let tests = vec!["read", "write", "exec"]; + let tests = [test_path(read), test_path(write), test_path(exec)]; + // Re-execute the current test binary so the subprocess + // runs with the same feature set as this test run. A + // nested `cargo test` would rebuild with default + // features, which do not compile on every supported host + // (e.g. the default `kvm`/`mshv3` features fail to build + // on macOS). + let exe = std::env::current_exe().expect("current_exe"); for test in tests { - let triple = std::env::var("TARGET_TRIPLE").ok(); - let target_args = if let Some(triple) = triple.filter(|t| !t.is_empty()) { - vec!["--target".to_string(), triple.to_string()] - } else { - vec![] - }; - let output = std::process::Command::new("cargo") - .args(["test", "-p", "hyperlight-host", "--lib"]) - .args(target_args) - .args(["--", "--ignored", test]) + let output = std::process::Command::new(&exe) + .args(["--ignored", "--exact", "--test-threads=1", test]) .stdin(std::process::Stdio::null()) .output() .expect("Unable to launch tests"); @@ -2544,6 +2867,17 @@ mod tests { } } } + + /// Derive a libtest filter path for a test function from its + /// type name. Strips the leading crate-name segment that + /// `type_name` includes but libtest does not. + fn test_path(_: F) -> &'static str { + let full = std::any::type_name::(); + let (_, rest) = full + .split_once("::") + .expect("type_name of a function item is always qualified by the crate name"); + rest + } } #[cfg(not(miri))] @@ -2654,14 +2988,25 @@ mod tests { println!("survived_guard"); } - /// Loads from the byte immediately after the mapping. + /// Loads from the byte immediately after the mapped span. #[test] #[ignore] pub(super) fn trailing_guard_page_traps() { let tmp = make_temp_file(PAGE_SIZE_USIZE); let rsm = ReadonlySharedMemory::from_file(tmp.as_file(), PAGE_SIZE_USIZE) .expect("from_file should succeed"); - let guard_ptr = unsafe { rsm.base_ptr().add(rsm.mem_size()) }; + // The mapped span between the guards is `mem_size()` + // rounded up to the guard size, so the trailing guard + // starts past the padding. The guard spans one host + // page on unix, which may exceed PAGE_SIZE_USIZE + // (e.g. 16 KiB on Apple silicon). + #[cfg(unix)] + let mapped_span = rsm + .mem_size() + .next_multiple_of(crate::mem::shared_mem::guard_page_size()); + #[cfg(windows)] + let mapped_span = rsm.mem_size(); + let guard_ptr = unsafe { rsm.base_ptr().add(mapped_span) }; println!("reached_guard"); let _ = unsafe { std::ptr::read_volatile(guard_ptr) }; println!("survived_guard"); @@ -2761,13 +3106,22 @@ mod tests { } /// Returns true if `status` indicates the process died from a - /// memory access fault (SIGSEGV on unix, STATUS_ACCESS_VIOLATION - /// (or 0xDEAD) on Windows). + /// memory access fault (SIGSEGV on unix, SIGBUS on macOS — + /// which reports faults on PROT_NONE pages as + /// EXC_BAD_ACCESS/KERN_PROTECTION_FAILURE delivered as SIGBUS — + /// or STATUS_ACCESS_VIOLATION (or 0xDEAD) on Windows). fn killed_by_access_violation(status: &std::process::ExitStatus) -> bool { #[cfg(unix)] { use std::os::unix::process::ExitStatusExt; - status.signal() == Some(libc::SIGSEGV) + let access_violation_signals = if cfg!(target_os = "macos") { + [libc::SIGSEGV, libc::SIGBUS].as_slice() + } else { + [libc::SIGSEGV].as_slice() + }; + status + .signal() + .is_some_and(|s| access_violation_signals.contains(&s)) } #[cfg(windows)] { diff --git a/src/hyperlight_host/src/sandbox/config.rs b/src/hyperlight_host/src/sandbox/config.rs index f12387a0b..d83dbe4b4 100644 --- a/src/hyperlight_host/src/sandbox/config.rs +++ b/src/hyperlight_host/src/sandbox/config.rs @@ -142,19 +142,19 @@ impl SandboxConfiguration { } /// Sets the interrupt retry delay - #[cfg(target_os = "linux")] + #[cfg(unix)] pub fn set_interrupt_retry_delay(&mut self, delay: Duration) { self.interrupt_retry_delay = delay; } /// Get the delay between retries for interrupts - #[cfg(target_os = "linux")] + #[cfg(unix)] pub fn get_interrupt_retry_delay(&self) -> Duration { self.interrupt_retry_delay } /// Get the signal offset from `SIGRTMIN` used to determine the signal number for interrupting the VCPU thread - #[cfg(target_os = "linux")] + #[cfg(unix)] pub fn get_interrupt_vcpu_sigrtmin_offset(&self) -> u8 { self.interrupt_vcpu_sigrtmin_offset } diff --git a/src/hyperlight_host/src/sandbox/file_mapping.rs b/src/hyperlight_host/src/sandbox/file_mapping.rs index 4f3ed2a4d..60e0e8b74 100644 --- a/src/hyperlight_host/src/sandbox/file_mapping.rs +++ b/src/hyperlight_host/src/sandbox/file_mapping.rs @@ -36,6 +36,8 @@ use tracing::{Span, instrument}; use crate::HyperlightError; #[cfg(target_os = "windows")] use crate::hypervisor::wrappers::HandleWrapper; +#[cfg(target_os = "macos")] +use crate::mem::memory_region::{HostGuestMemoryRegion, HostRegionBase, MemoryRegionKind}; #[cfg(target_os = "windows")] use crate::mem::memory_region::{HostRegionBase, MemoryRegionKind}; use crate::mem::memory_region::{MemoryRegion, MemoryRegionFlags, MemoryRegionType}; @@ -44,7 +46,7 @@ use crate::{Result, log_then_return}; /// A prepared (host-side) file mapping ready to be applied to a VM. /// /// Created by [`prepare_file_cow`]. The host-side OS resources (file -/// mapping handle + view on Windows, mmap on Linux) are held here +/// mapping handle + view on Windows, mmap on Unix) are held here /// until consumed by the VM-side apply step. /// /// If dropped without being consumed, the `Drop` impl releases all @@ -70,9 +72,9 @@ pub(crate) enum HostFileResources { mapping_handle: HandleWrapper, view_base: *mut c_void, }, - /// Linux: `mmap` base pointer. - #[cfg(target_os = "linux")] - Linux { + /// Unix: `mmap` base pointer. + #[cfg(unix)] + Unix { mmap_base: *mut c_void, mmap_size: usize, }, @@ -103,8 +105,8 @@ impl Drop for PreparedFileMapping { tracing::error!("PreparedFileMapping::drop: CloseHandle failed: {:?}", e); } }, - #[cfg(target_os = "linux")] - HostFileResources::Linux { + #[cfg(unix)] + HostFileResources::Unix { mmap_base, mmap_size, } => unsafe { @@ -121,7 +123,7 @@ impl Drop for PreparedFileMapping { } // SAFETY: The raw pointers in HostFileResources point to kernel-managed -// mappings (Windows file mapping views / Linux mmap regions), not aliased +// mappings (Windows file mapping views / Unix mmap regions), not aliased // user-allocated heap memory. Ownership is fully contained within the // struct, and cleanup APIs (UnmapViewOfFile, CloseHandle, munmap) are // thread-safe. @@ -168,8 +170,8 @@ impl PreparedFileMapping { region_type: MemoryRegionType::MappedFile, }) } - #[cfg(target_os = "linux")] - HostFileResources::Linux { + #[cfg(all(unix, not(target_os = "macos")))] + HostFileResources::Unix { mmap_base, mmap_size, } => { @@ -188,6 +190,36 @@ impl PreparedFileMapping { region_type: MemoryRegionType::MappedFile, }) } + #[cfg(target_os = "macos")] + HostFileResources::Unix { + mmap_base, + mmap_size, + } => { + let guest_start = self.guest_base as usize; + let guest_end = guest_start.checked_add(self.size).ok_or_else(|| { + crate::HyperlightError::Error(format!( + "guest_region overflow: {:#x} + {:#x}", + guest_start, self.size + )) + })?; + // MappedFile regions stay file-backed (the surrogate + // maps them by path, not by shm object), so there is no + // shm name to carry here; `base` is the host VA of the + // mapping, used by the in-process HVF backend. + let host_base = HostRegionBase { + name: String::new(), + offset: 0, + base: *mmap_base as usize, + }; + let host_end = + ::add(host_base.clone(), *mmap_size); + Ok(MemoryRegion { + host_region: host_base..host_end, + guest_region: guest_start..guest_end, + flags: MemoryRegionFlags::READ | MemoryRegionFlags::EXECUTE, + region_type: MemoryRegionType::MappedFile, + }) + } } } @@ -348,7 +380,7 @@ pub(crate) fn prepare_file_cow(file_path: &Path, guest_base: u64) -> Result Date: Wed, 22 Jul 2026 15:59:36 +0100 Subject: [PATCH 04/14] feat(host): add direct HVF backend for macOS aarch64 Add a Hypervisor.framework backend for Apple silicon: - HvfVm implementing the VirtualMachine trait over the shared hyperlight_hvf::core wrapper (map/unmap with slot tracking, regs/fpu/ sregs accessors, vCPU run with ESR-decoded exits: IO-page MMIO writes become IoOut/Halt, aborts become MmioRead/MmioWrite, WFI is skipped, vtimer exits are ignored). - MacOSInterruptHandle using hv_vcpus_exit (the only HVF call allowed from a non-owning thread) for sandbox cancellation. - HypervisorType::Hvf detection and wiring into HyperlightVm::new for aarch64; CpuVendor::current() for aarch64-macos and a Hypervisor::Hvf snapshot variant. - Note: HVF binds a VM to its creating process (one VM per process), so this backend supports one live sandbox per process; multi-sandbox support will delegate VMs to surrogate processes. Signed-off-by: Sienna Meridian Satterwhite --- .../src/hypervisor/hyperlight_vm/aarch64.rs | 28 +- .../src/hypervisor/hyperlight_vm/mod.rs | 10 +- src/hyperlight_host/src/hypervisor/mod.rs | 132 ++++++++- .../src/hypervisor/virtual_machine/hvf.rs | 260 ++++++++++++++++++ .../src/hypervisor/virtual_machine/mod.rs | 23 +- .../src/sandbox/snapshot/file/config.rs | 20 +- 6 files changed, 461 insertions(+), 12 deletions(-) create mode 100644 src/hyperlight_host/src/hypervisor/virtual_machine/hvf.rs diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs index 770959c14..f3e2039de 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs @@ -17,24 +17,33 @@ limitations under the License. // TODO(aarch64): implement arch-specific HyperlightVm methods use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64}; +#[cfg(any(kvm, mshv3))] +use std::sync::atomic::AtomicU64; +#[cfg(any(kvm, mshv3, hvf))] +use std::sync::atomic::{AtomicBool, AtomicU8}; use super::{ AccessPageTableError, CreateHyperlightVmError, DispatchGuestCallError, HyperlightVm, InitializeError, }; +use crate::hypervisor::InterruptHandleImpl; +#[cfg(any(kvm, mshv3))] +use crate::hypervisor::LinuxInterruptHandle; +#[cfg(hvf)] +use crate::hypervisor::MacOSInterruptHandle; #[cfg(gdb)] use crate::hypervisor::gdb::{DebugCommChannel, DebugMsg, DebugResponse}; use crate::hypervisor::hyperlight_vm::get_guest_log_filter; use crate::hypervisor::regs::{CommonFpu, CommonRegisters, CommonSpecialRegisters}; +#[cfg(hvf)] +use crate::hypervisor::virtual_machine::hvf::HvfVm; #[cfg(kvm)] use crate::hypervisor::virtual_machine::kvm::KvmVm; -#[cfg(kvm)] +#[cfg(any(kvm, mshv3, hvf))] use crate::hypervisor::virtual_machine::{HypervisorType, VmError}; use crate::hypervisor::virtual_machine::{ RegisterError, ResetVcpuError, VirtualMachine, get_available_hypervisor, }; -use crate::hypervisor::{InterruptHandleImpl, LinuxInterruptHandle}; use crate::mem::mgr::{SandboxMemoryManager, SnapshotSharedMemory}; use crate::mem::shared_mem::{GuestSharedMemory, HostSharedMemory}; use crate::sandbox::SandboxConfiguration; @@ -67,10 +76,13 @@ impl HyperlightVm { // TODO: mshv support #[cfg(mshv3)] Some(HypervisorType::Mshv) => return Err(CreateHyperlightVmError::NoHypervisorFound), + #[cfg(hvf)] + Some(HypervisorType::Hvf) => Box::new(HvfVm::new().map_err(VmError::CreateVm)?), None => return Err(CreateHyperlightVmError::NoHypervisorFound), }; vm.set_sregs(&CommonSpecialRegisters::defaults(root_pt_addr)) .map_err(VmError::Register)?; + #[cfg(any(kvm, mshv3))] let interrupt_handle: Arc = Arc::new(LinuxInterruptHandle { state: AtomicU8::new(0), tid: AtomicU64::new(unsafe { libc::pthread_self() as u64 }), @@ -78,6 +90,16 @@ impl HyperlightVm { sig_rt_min_offset: config.get_interrupt_vcpu_sigrtmin_offset(), dropped: AtomicBool::new(false), }); + #[cfg(hvf)] + let interrupt_handle: Arc = { + // `config` only carries Linux signal settings today. + let _ = config; + Arc::new(MacOSInterruptHandle { + state: AtomicU8::new(0), + vcpu: vm.vcpu_id(), + dropped: AtomicBool::new(false), + }) + }; let snapshot_slot = 0u32; let scratch_slot = 1u32; diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs index 2e545aba7..d5c7a72a5 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs @@ -422,13 +422,15 @@ impl HyperlightVm { &mut self, region: &MemoryRegion, ) -> std::result::Result<(), MapRegionError> { + #[allow(clippy::useless_conversion, clippy::clone_on_copy)] + let host_region_start: usize = region.host_region.start.clone().into(); + #[allow(clippy::useless_conversion, clippy::clone_on_copy)] + let host_region_end: usize = region.host_region.end.clone().into(); if [ region.guest_region.start, region.guest_region.end, - #[allow(clippy::useless_conversion)] - region.host_region.start.into(), - #[allow(clippy::useless_conversion)] - region.host_region.end.into(), + host_region_start, + host_region_end, ] .iter() .any(|x| x % self.page_size != 0) diff --git a/src/hyperlight_host/src/hypervisor/mod.rs b/src/hyperlight_host/src/hypervisor/mod.rs index 732f08563..da4acc22b 100644 --- a/src/hyperlight_host/src/hypervisor/mod.rs +++ b/src/hyperlight_host/src/hypervisor/mod.rs @@ -41,6 +41,8 @@ pub(crate) mod hyperlight_vm; use std::fmt::Debug; #[cfg(any(kvm, mshv3))] use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering}; +#[cfg(any(target_os = "windows", hvf))] +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; #[cfg(target_os = "windows")] use std::sync::atomic::{AtomicU8, Ordering}; #[cfg(any(kvm, mshv3))] @@ -456,7 +458,135 @@ impl InterruptHandle for WindowsInterruptHandle { } } -#[cfg(all(test, any(target_os = "windows", kvm)))] +#[cfg(hvf)] +#[derive(Debug)] +pub(super) struct MacOSInterruptHandle { + /// Atomic value packing vcpu execution state. + /// + /// Bit layout: + /// - Bit 1: RUNNING_BIT - set when vcpu is actively running + /// - Bit 0: CANCEL_BIT - set when cancellation has been requested + /// + /// `hv_vcpus_exit()` forces an immediate exit of a running vCPU and is a + /// no-op-safe call otherwise, but we still track the RUNNING_BIT to + /// report whether the vCPU was actually interrupted. + /// + /// CANCEL_BIT persists across vcpu exits/re-entries within a single `VirtualCPU::run()` call + /// (e.g., during host function calls), but is cleared at the start of each new `VirtualCPU::run()` call. + pub(super) state: AtomicU8, + + /// The HVF vCPU ID to interrupt. `hv_vcpus_exit` is documented as + /// callable from any thread, and vCPU IDs are kernel-validated (a stale + /// cancel of a destroyed vCPU simply fails), so no lock is needed to + /// guard against concurrent vCPU destruction. + pub(super) vcpu: u64, + + /// Whether the corresponding VM has been dropped. + pub(super) dropped: AtomicBool, +} + +#[cfg(hvf)] +impl MacOSInterruptHandle { + const RUNNING_BIT: u8 = 1 << 1; + const CANCEL_BIT: u8 = 1 << 0; + #[cfg(gdb)] + const DEBUG_INTERRUPT_BIT: u8 = 1 << 2; + + /// Force the vCPU out of `hv_vcpu_run` if it is currently running. + fn interrupt(&self) -> bool { + // Acquire ordering to synchronize with the Release in set_running() + let state = self.state.load(Ordering::Acquire); + if state & Self::RUNNING_BIT == 0 { + return false; + } + + if self.dropped.load(Ordering::Acquire) { + return false; + } + + // SAFETY: `hv_vcpus_exit` may be called from any thread, and the + // vCPU ID is kernel-validated, so a stale cancel after the vCPU was + // destroyed fails harmlessly. + unsafe { + applevisor_sys::hv_vcpus_exit(&self.vcpu, 1) + == applevisor_sys::hv_error_t::HV_SUCCESS as i32 + } + } +} + +#[cfg(hvf)] +impl InterruptHandleImpl for MacOSInterruptHandle { + fn set_running(&self) { + // Release ordering to ensure prior memory operations are visible when another thread observes running=true + self.state.fetch_or(Self::RUNNING_BIT, Ordering::Release); + } + + fn is_cancelled(&self) -> bool { + // Acquire ordering to synchronize with the Release in kill() + // This ensures we see the CANCEL_BIT set by the interrupt thread + self.state.load(Ordering::Acquire) & Self::CANCEL_BIT != 0 + } + + fn clear_cancel(&self) { + // Release ordering to ensure that any operations from the previous run() + // are visible to other threads. While this is typically called by the vcpu thread + // at the start of run(), the VM itself can move between threads across guest calls. + self.state.fetch_and(!Self::CANCEL_BIT, Ordering::Release); + } + + fn clear_running(&self) { + // Release ordering to ensure all vcpu operations are visible before clearing running + self.state.fetch_and(!Self::RUNNING_BIT, Ordering::Release); + } + + fn is_debug_interrupted(&self) -> bool { + #[cfg(gdb)] + { + self.state.load(Ordering::Acquire) & Self::DEBUG_INTERRUPT_BIT != 0 + } + #[cfg(not(gdb))] + { + false + } + } + + #[cfg(gdb)] + fn clear_debug_interrupt(&self) { + self.state + .fetch_and(!Self::DEBUG_INTERRUPT_BIT, Ordering::Release); + } + + fn set_dropped(&self) { + // Release ordering to ensure all VM cleanup operations are visible + // to any thread that checks dropped() via Acquire + self.dropped.store(true, Ordering::Release); + } +} + +#[cfg(hvf)] +impl InterruptHandle for MacOSInterruptHandle { + fn kill(&self) -> bool { + // Release ordering ensures that any writes before kill() are visible to the vcpu thread + // when it checks is_cancelled() with Acquire ordering + self.state.fetch_or(Self::CANCEL_BIT, Ordering::Release); + self.interrupt() + } + + #[cfg(gdb)] + fn kill_from_debugger(&self) -> bool { + self.state + .fetch_or(Self::DEBUG_INTERRUPT_BIT, Ordering::Release); + self.interrupt() + } + + fn dropped(&self) -> bool { + // Acquire ordering to synchronize with the Release in set_dropped() + // This ensures we see all VM cleanup operations that happened before drop + self.dropped.load(Ordering::Acquire) + } +} + +#[cfg(all(test, any(target_os = "windows", kvm, hvf)))] pub(crate) mod tests { use std::sync::{Arc, Mutex}; diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/hvf.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf.rs new file mode 100644 index 000000000..8b40485a9 --- /dev/null +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf.rs @@ -0,0 +1,260 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! Direct HVF (Hypervisor.framework) backend for macOS on aarch64. +//! +//! This is the single-VM-per-process backend: HVF binds a VM to its creating +//! process, so only one sandbox backed by [`HvfVm`] can exist in a process at +//! a time. Multi-sandbox support uses the surrogate-process backend instead +//! (see `super::hvf_surrogate`), which delegates each sandbox's VM to a +//! helper process. The actual HVF logic lives in the shared +//! [`hyperlight_hvf::core`] crate; this file adapts it to the +//! [`VirtualMachine`] trait. + +use hyperlight_hvf::core::{self, FpuState, HvfError, Perms, Regs, Sregs}; +use tracing::{Span, instrument}; + +use crate::hypervisor::regs::{CommonFpu, CommonRegisters, CommonSpecialRegisters}; +use crate::hypervisor::virtual_machine::{ + CreateVmError, HypervisorError, MapMemoryError, RegisterError, RunVcpuError, UnmapMemoryError, + VirtualMachine, VmExit, +}; + +/// Return `true` if Hypervisor.framework is available. +/// +/// Requires the calling binary to hold the `com.apple.security.hypervisor` +/// entitlement; unsigned test binaries must be ad-hoc codesigned with it. +#[instrument(skip_all, parent = Span::current(), level = "Trace")] +pub(crate) fn is_hypervisor_present() -> bool { + core::is_hypervisor_present() +} + +impl From for HypervisorError { + fn from(e: HvfError) -> Self { + match e { + HvfError::Hv(code) => HypervisorError::HvfError(code), + HvfError::NoInstructionSyndrome => HypervisorError::HvfError(0), + } + } +} + +impl From for CommonRegisters { + fn from(r: Regs) -> Self { + CommonRegisters { + x: r.x, + sp: r.sp, + pc: r.pc, + pstate: r.pstate, + } + } +} + +impl From<&CommonRegisters> for Regs { + fn from(r: &CommonRegisters) -> Self { + Regs { + x: r.x, + sp: r.sp, + pc: r.pc, + pstate: r.pstate, + } + } +} + +impl From for CommonFpu { + fn from(f: FpuState) -> Self { + CommonFpu { + v: f.v, + fpsr: f.fpsr, + fpcr: f.fpcr, + } + } +} + +impl From<&CommonFpu> for FpuState { + fn from(f: &CommonFpu) -> Self { + FpuState { + v: f.v, + fpsr: f.fpsr, + fpcr: f.fpcr, + } + } +} + +impl From for CommonSpecialRegisters { + fn from(s: Sregs) -> Self { + CommonSpecialRegisters { + ttbr0_el1: s.ttbr0_el1, + tcr_el1: s.tcr_el1, + mair_el1: s.mair_el1, + sctlr_el1: s.sctlr_el1, + cpacr_el1: s.cpacr_el1, + vbar_el1: s.vbar_el1, + sp_el1: s.sp_el1, + } + } +} + +impl From<&CommonSpecialRegisters> for Sregs { + fn from(s: &CommonSpecialRegisters) -> Self { + Sregs { + ttbr0_el1: s.ttbr0_el1, + tcr_el1: s.tcr_el1, + mair_el1: s.mair_el1, + sctlr_el1: s.sctlr_el1, + cpacr_el1: s.cpacr_el1, + vbar_el1: s.vbar_el1, + sp_el1: s.sp_el1, + } + } +} + +impl From for VmExit { + fn from(e: core::VmExit) -> Self { + match e { + core::VmExit::Halt => VmExit::Halt(), + core::VmExit::IoOut(port, data) => VmExit::IoOut(port, data), + core::VmExit::MmioRead(addr) => VmExit::MmioRead(addr), + core::VmExit::MmioWrite(addr) => VmExit::MmioWrite(addr), + core::VmExit::Cancelled => VmExit::Cancelled(), + core::VmExit::Unknown(msg) => VmExit::Unknown(msg), + } + } +} + +/// The direct (in-process) HVF implementation of a single-vcpu VM. +#[derive(Debug)] +pub(crate) struct HvfVm { + inner: core::Vm, +} + +impl HvfVm { + pub(crate) fn new() -> std::result::Result { + Ok(Self { + inner: core::Vm::new().map_err(|e| CreateVmError::CreateVmFd(e.into()))?, + }) + } +} + +impl VirtualMachine for HvfVm { + unsafe fn map_memory( + &mut self, + (slot, region): (u32, &crate::mem::memory_region::MemoryRegion), + ) -> std::result::Result<(), MapMemoryError> { + let perms = Perms { + read: region + .flags + .contains(crate::mem::memory_region::MemoryRegionFlags::READ), + write: region + .flags + .contains(crate::mem::memory_region::MemoryRegionFlags::WRITE), + exec: region + .flags + .contains(crate::mem::memory_region::MemoryRegionFlags::EXECUTE), + }; + // SAFETY: the caller guarantees the host region is valid and outlives + // the mapping (see the trait's `map_memory` contract). + unsafe { + self.inner.map_memory( + slot, + region.guest_region.start as u64, + usize::from(region.host_region.start.clone()), + region.guest_region.end - region.guest_region.start, + perms, + ) + } + .map_err(|e| MapMemoryError::Hypervisor(e.into())) + } + + fn unmap_memory( + &mut self, + (slot, region): (u32, &crate::mem::memory_region::MemoryRegion), + ) -> std::result::Result<(), UnmapMemoryError> { + self.inner + .unmap_memory( + slot, + region.guest_region.start as u64, + region.guest_region.end - region.guest_region.start, + ) + .map_err(|e| UnmapMemoryError::Hypervisor(e.into())) + } + + fn run_vcpu( + &mut self, + #[cfg(feature = "trace_guest")] tc: &mut SandboxTraceContext, + ) -> std::result::Result { + match self.inner.run_vcpu() { + Ok(exit) => Ok(exit.into()), + Err(HvfError::NoInstructionSyndrome) => Err(RunVcpuError::ParseGpaAccessInfo), + Err(e) => Err(RunVcpuError::Unknown(e.into())), + } + } + + fn regs(&self) -> std::result::Result { + self.inner + .regs() + .map(Into::into) + .map_err(|e| RegisterError::GetRegs(e.into())) + } + + fn set_regs(&self, regs: &CommonRegisters) -> std::result::Result<(), RegisterError> { + self.inner + .set_regs(®s.into()) + .map_err(|e| RegisterError::SetRegs(e.into())) + } + + fn fpu(&self) -> std::result::Result { + self.inner + .fpu() + .map(Into::into) + .map_err(|e| RegisterError::GetFpu(e.into())) + } + + fn set_fpu(&self, fpu: &CommonFpu) -> std::result::Result<(), RegisterError> { + self.inner + .set_fpu(&fpu.into()) + .map_err(|e| RegisterError::SetFpu(e.into())) + } + + fn sregs(&self) -> std::result::Result { + self.inner + .sregs() + .map(Into::into) + .map_err(|e| RegisterError::GetSregs(e.into())) + } + + fn set_sregs(&self, sregs: &CommonSpecialRegisters) -> std::result::Result<(), RegisterError> { + self.inner + .set_sregs(&sregs.into()) + .map_err(|e| RegisterError::SetSregs(e.into())) + } + + fn debug_regs( + &self, + ) -> std::result::Result { + todo!("debug registers are not supported on aarch64") + } + + fn set_debug_regs( + &self, + _drs: &crate::hypervisor::regs::CommonDebugRegs, + ) -> std::result::Result<(), RegisterError> { + todo!("debug registers are not supported on aarch64") + } + + fn vcpu_id(&self) -> u64 { + self.inner.vcpu_id() + } +} diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs index dac344711..ba4175743 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs @@ -28,6 +28,9 @@ use crate::mem::memory_region::MemoryRegion; #[cfg(feature = "trace_guest")] use crate::sandbox::trace::TraceContext as SandboxTraceContext; +/// HVF (Hypervisor.framework) functionality (macOS, aarch64) +#[cfg(hvf)] +pub(crate) mod hvf; /// KVM (Kernel-based Virtual Machine) functionality (linux) #[cfg(kvm)] pub(crate) mod kvm; @@ -77,6 +80,12 @@ pub fn get_available_hypervisor() -> &'static Option { } else { None } + } else if #[cfg(hvf)] { + if hvf::is_hypervisor_present() { + Some(HypervisorType::Hvf) + } else { + None + } } else { None } @@ -102,6 +111,9 @@ pub(crate) enum HypervisorType { #[cfg(target_os = "windows")] Whp, + + #[cfg(hvf)] + Hvf, } /// Minimum XSAVE buffer size: 512 bytes legacy region + 64 bytes header. @@ -145,10 +157,10 @@ pub(crate) enum VmExit { Unknown(String), /// The operation should be retried, for example this can happen on Linux where a call to run the CPU can return EAGAIN #[cfg_attr( - any(target_os = "windows", feature = "hw-interrupts"), + any(target_os = "windows", feature = "hw-interrupts", hvf), expect( dead_code, - reason = "Retry() is never constructed on Windows or with hw-interrupts (EAGAIN causes continue instead)" + reason = "Retry() is never constructed on Windows, with hw-interrupts (EAGAIN causes continue instead), or on HVF" ) )] Retry(), @@ -305,6 +317,9 @@ pub enum HypervisorError { #[cfg(target_os = "windows")] #[error("Windows error: {0}")] WindowsError(#[from] windows_result::Error), + #[cfg(hvf)] + #[error("HVF error: {0:#x}")] + HvfError(u32), } /// Trait for single-vCPU VMs. Provides a common interface for basic VM operations. @@ -383,6 +398,10 @@ pub(crate) trait VirtualMachine: Debug + Send { /// Get partition handle #[cfg(target_os = "windows")] fn partition_handle(&self) -> windows::Win32::System::Hypervisor::WHV_PARTITION_HANDLE; + /// Get the HVF vCPU ID, used to construct the interrupt handle + /// (`hv_vcpus_exit` may be called from any thread) + #[cfg(hvf)] + fn vcpu_id(&self) -> u64; } #[cfg(test)] diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/config.rs b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs index 5e9fe6c02..c4f7f81ff 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file/config.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs @@ -64,6 +64,7 @@ pub(super) enum Hypervisor { Kvm, Mshv, Whp, + Hvf, } impl Hypervisor { @@ -79,6 +80,12 @@ impl Hypervisor { Some(HypervisorType::Mshv) => Some(Self::Mshv), #[cfg(target_os = "windows")] Some(HypervisorType::Whp) => Some(Self::Whp), + #[cfg(hvf)] + Some(HypervisorType::Hvf) => Some(Self::Hvf), + // `HypervisorType` has no variants when no backend is compiled + // (e.g. aarch64 without any hypervisor feature). + #[cfg(not(any(kvm, mshv3, target_os = "windows", hvf)))] + Some(_) => None, None => None, } } @@ -88,6 +95,7 @@ impl Hypervisor { Self::Kvm => "KVM", Self::Mshv => "MSHV", Self::Whp => "WHP", + Self::Hvf => "HVF", } } @@ -99,6 +107,7 @@ impl Hypervisor { Self::Kvm => "kvm", Self::Mshv => "mshv", Self::Whp => "whp", + Self::Hvf => "hvf", } } } @@ -132,6 +141,12 @@ impl CpuVendor { // `0x` prefix padded to width 4, e.g. Apple `0x61`, Arm `0x41`. Self(format!("{implementer:#04x}")) } + #[cfg(all(target_arch = "aarch64", target_os = "macos"))] + { + // macOS does not emulate MIDR_EL1 reads from EL0, but every + // aarch64 macOS host is Apple silicon (implementer `0x61`). + Self("0x61".to_string()) + } } pub(super) fn as_str(&self) -> &str { @@ -671,7 +686,7 @@ mod tests { matches!(v, "GenuineIntel" | "AuthenticAMD"), "unrecognized x86_64 CPU vendor: {v:?}" ); - #[cfg(all(target_arch = "aarch64", target_os = "linux"))] + #[cfg(all(target_arch = "aarch64", any(target_os = "linux", target_os = "macos")))] // MIDR_EL1 implementer byte for Apple silicon. assert_eq!(v, "0x61", "unexpected aarch64 CPU implementer"); } @@ -986,7 +1001,8 @@ mod schema_pin { const PINNED_HYPERVISOR: &str = r#"[ "kvm", "mshv", - "whp" + "whp", + "hvf" ]"#; fn assert_round_trip(pinned: &str) { From c6423252974552e8636dd0fe8626798e908a0630 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Wed, 22 Jul 2026 15:59:48 +0100 Subject: [PATCH 05/14] chore: add test-hvf recipe and hypervisor entitlement - dev/hvf-entitlements.plist: com.apple.security.hypervisor, required by Hypervisor.framework; test and surrogate binaries are ad-hoc codesigned with it. - Justfile test-hvf recipe: build, codesign, and run hyperlight-host tests with the hvf feature. - Bump pinned cargo-hyperlight to 0.1.13 for guest builds (macOS hosts additionally need the toolchain to run through rustup with the pinned 1.94 toolchain; newer rustc rejects custom target specs). Signed-off-by: Sienna Meridian Satterwhite --- Justfile | 15 ++++++++++++++- dev/hvf-entitlements.plist | 8 ++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 dev/hvf-entitlements.plist diff --git a/Justfile b/Justfile index 5112f2f88..fc865dadb 100644 --- a/Justfile +++ b/Justfile @@ -8,7 +8,7 @@ bin-suffix := if os() == "windows" { ".bat" } else { ".sh" } nightly-toolchain := "nightly-2026-02-27" # Pinned cargo-hyperlight version used to build the guest sysroot. Keep this in # lockstep with the version pinned in flake.nix. -cargo-hyperlight-version := "0.1.12" +cargo-hyperlight-version := "0.1.13" ################ ### cross-rs ### @@ -247,6 +247,19 @@ test-isolated target=default-target features="" : @# metrics tests {{ cargo-cmd }} test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F function_call_metrics," + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --lib -- metrics::tests::test_metrics_are_emitted --exact +# Ad-hoc codesigns hyperlight test binaries with the Hypervisor.framework +# entitlement and runs the host test suite with the HVF backend (macOS/aarch64). +# Guest binaries must already be built (see build-rust-guests). +test-hvf target=default-target: + #!/usr/bin/env bash + set -euo pipefail + profile={{ if target == "debug" { "dev" } else { target } }} + dir={{ if target == "debug" { "debug" } else { "release" } }} + {{ cargo-cmd }} test -p hyperlight-host --no-default-features -F hvf --profile=$profile --no-run + find {{ justfile_directory() }}/target/$dir/deps -maxdepth 1 -type f -perm +111 \ + -exec codesign --sign - --entitlements {{ justfile_directory() }}/dev/hvf-entitlements.plist --force {} \; + {{ cargo-cmd }} test -p hyperlight-host --no-default-features -F hvf --profile=$profile + # runs integration tests test-integration target=default-target features="": @# run execute_on_heap test with feature "executable_heap" on (runs with off during normal tests) diff --git a/dev/hvf-entitlements.plist b/dev/hvf-entitlements.plist new file mode 100644 index 000000000..154f3308e --- /dev/null +++ b/dev/hvf-entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.hypervisor + + + From 8c5a3a86c38db2f2caa6c02887eb533fcb2aac21 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Wed, 22 Jul 2026 16:32:20 +0100 Subject: [PATCH 06/14] feat(host): add HVF surrogate server and process manager HVF allows only one VM per process, so multi-sandbox support delegates each sandbox's VM to a per-sandbox surrogate process: - hvf_surrogate: a small server binary owning one HVF VM, speaking the hyperlight_hvf::proto protocol over an inherited unix socket. The vCPU thread executes all VM operations; a reader thread dispatches requests and answers Cancel immediately via hv_vcpus_exit; a writer thread serializes responses. Guest memory is mapped from shared POSIX shm objects by name, never copied. - hvf_surrogate_manager: pooled surrogate lifecycle mirroring the Windows manager (rust-embed extraction, hash-stamped binary name, env sizing via HYPERLIGHT_INITIAL_SURROGATES / HYPERLIGHT_MAX_SURROGATES, kill-on-drop). The extracted binary is ad-hoc codesigned with the hypervisor entitlement before first spawn. - build.rs: nested cargo build of the surrogate on macOS/aarch64 when the hvf feature is enabled. Signed-off-by: Sienna Meridian Satterwhite --- src/hyperlight_host/Cargo.toml | 3 +- src/hyperlight_host/build.rs | 86 +++ .../src/hvf_surrogate/Cargo.toml_temp_name | 31 + .../src/hvf_surrogate/build.rs | 23 + .../src/hvf_surrogate/src/main.rs | 440 ++++++++++++ .../src/hypervisor/hvf_surrogate_manager.rs | 650 ++++++++++++++++++ src/hyperlight_host/src/hypervisor/mod.rs | 4 + 7 files changed, 1236 insertions(+), 1 deletion(-) create mode 100644 src/hyperlight_host/src/hvf_surrogate/Cargo.toml_temp_name create mode 100644 src/hyperlight_host/src/hvf_surrogate/build.rs create mode 100644 src/hyperlight_host/src/hvf_surrogate/src/main.rs create mode 100644 src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs diff --git a/src/hyperlight_host/Cargo.toml b/src/hyperlight_host/Cargo.toml index d03053636..2b748fe9c 100644 --- a/src/hyperlight_host/Cargo.toml +++ b/src/hyperlight_host/Cargo.toml @@ -87,6 +87,7 @@ mshv-ioctls = { version = "0.6", optional = true} [target.'cfg(all(target_os = "macos", target_arch = "aarch64"))'.dependencies] applevisor-sys = { version = "1.0", optional = true } hyperlight-hvf = { workspace = true, optional = true } +rust-embed = { version = "8.11.0", optional = true, features = ["debug-embed", "include-exclude", "interpolate-folder-path"] } [dev-dependencies] uuid = { version = "1.23.3", features = ["v4"] } @@ -140,7 +141,7 @@ trace_guest = ["dep:opentelemetry", "dep:tracing-opentelemetry", "dep:hyperlight mem_profile = [ "trace_guest", "dep:framehop", "dep:fallible-iterator", "hyperlight-common/mem_profile" ] kvm = ["dep:kvm-bindings", "dep:kvm-ioctls"] mshv3 = ["dep:mshv-bindings", "dep:mshv-ioctls"] -hvf = ["dep:applevisor-sys", "dep:hyperlight-hvf"] +hvf = ["dep:applevisor-sys", "dep:hyperlight-hvf", "dep:rust-embed"] hw-interrupts = [] # This enables easy debug in the guest gdb = ["dep:gdbstub", "dep:gdbstub_arch"] diff --git a/src/hyperlight_host/build.rs b/src/hyperlight_host/build.rs index c52b7c598..be3cbe840 100644 --- a/src/hyperlight_host/build.rs +++ b/src/hyperlight_host/build.rs @@ -101,6 +101,92 @@ fn main() -> Result<()> { ); } + // aarch64 macOS with the `hvf` feature: build the HVF surrogate server + // binary and export its location (plus the hypervisor entitlement plist) + // so the host library can embed both and extract/codesign them at + // runtime. Mirrors the Windows surrogate pipeline above. + if std::env::var("CARGO_CFG_TARGET_OS")? == "macos" + && std::env::var("CARGO_CFG_TARGET_ARCH")? == "aarch64" + && std::env::var("CARGO_FEATURE_HVF").is_ok() + { + println!("cargo:rerun-if-changed=src/hvf_surrogate/src/main.rs"); + println!("cargo:rerun-if-changed=src/hvf_surrogate/build.rs"); + println!("cargo:rerun-if-changed=src/hvf_surrogate/Cargo.toml_temp_name"); + println!("cargo:rerun-if-changed=../../dev/hvf-entitlements.plist"); + + // Copy the hvf_surrogate sources into a temp directory because we + // cannot include a file named `Cargo.toml` inside this package (see + // the Windows branch above). The copied tree lives outside the + // workspace, so the path dependency on `hyperlight-hvf` is rewritten + // to an absolute path. + let out_dir = std::env::var("OUT_DIR")?; + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")?; + std::fs::create_dir_all(format!("{out_dir}/hvf_surrogate/src"))?; + std::fs::copy( + format!("{manifest_dir}/src/hvf_surrogate/src/main.rs"), + format!("{out_dir}/hvf_surrogate/src/main.rs"), + )?; + std::fs::copy( + format!("{manifest_dir}/src/hvf_surrogate/build.rs"), + format!("{out_dir}/hvf_surrogate/build.rs"), + )?; + let hvf_path = std::fs::canonicalize(format!("{manifest_dir}/../hyperlight_hvf"))?; + let manifest = std::fs::read_to_string(format!( + "{manifest_dir}/src/hvf_surrogate/Cargo.toml_temp_name" + ))? + .replace("@HYPERLIGHT_HVF_PATH@", &hvf_path.to_string_lossy()); + let target_manifest_path = format!("{out_dir}/hvf_surrogate/Cargo.toml"); + std::fs::write(&target_manifest_path, manifest)?; + + // The entitlements plist is embedded in the host library and + // re-applied with `codesign` when the surrogate binary is extracted. + let entitlements_path = format!("{out_dir}/hvf-entitlements.plist"); + std::fs::copy( + format!("{manifest_dir}/../../dev/hvf-entitlements.plist"), + &entitlements_path, + )?; + + // Note: CARGO_TARGET_DIR cannot be the same as the CARGO_TARGET_DIR + // for hyperlight-host, otherwise the build script will hang. Using a + // sub directory works tho! (see the Windows branch above) + let target_dir = std::path::PathBuf::from(&out_dir).join("../../hvf-surr"); + + let profile = std::env::var("PROFILE")?; + let build_profile = if profile.to_lowercase() == "debug" { + "dev".to_string() + } else { + profile.clone() + }; + + let target_triple = std::env::var("TARGET")?; + + let status = std::process::Command::new("cargo") + .env("CARGO_TARGET_DIR", &target_dir) + .arg("build") + .arg("--manifest-path") + .arg(&target_manifest_path) + .arg("--target") + .arg(&target_triple) + .arg("--profile") + .arg(build_profile) + .status() + .expect("Failed to execute cargo build for hvf_surrogate"); + + if !status.success() { + panic!("Failed to build hvf_surrogate"); + } + + let surrogate_binary_dir = std::path::PathBuf::from(&target_dir) + .join(&target_triple) + .join(&profile); + + println!( + "cargo:rustc-env=HYPERLIGHT_HVF_SURROGATE_DIR={}", + &surrogate_binary_dir.display() + ); + println!("cargo:rustc-env=HYPERLIGHT_HVF_ENTITLEMENTS_PATH={entitlements_path}"); + } + // Makes #[cfg(kvm)] == #[cfg(all(feature = "kvm", target_os = "linux"))] // Essentially the kvm and mshv3 features are ignored on windows as long as you use #[cfg(kvm)] and not #[cfg(feature = "kvm")]. // You should never use #[cfg(feature = "kvm")] or #[cfg(feature = "mshv3")] in the codebase. diff --git a/src/hyperlight_host/src/hvf_surrogate/Cargo.toml_temp_name b/src/hyperlight_host/src/hvf_surrogate/Cargo.toml_temp_name new file mode 100644 index 000000000..a2382e763 --- /dev/null +++ b/src/hyperlight_host/src/hvf_surrogate/Cargo.toml_temp_name @@ -0,0 +1,31 @@ +[package] +name = "hvf_surrogate" +version = "0.1.0" +edition = "2021" + +[workspace] + +[dependencies] +# This crate is copied into OUT_DIR and built by hyperlight-host's build.rs, +# so a relative path would not resolve; the build script rewrites the +# @HYPERLIGHT_HVF_PATH@ placeholder to the absolute path of the +# hyperlight_hvf crate. +hyperlight-hvf = { path = "@HYPERLIGHT_HVF_PATH@" } +libc = "0.2.186" + +[profile.dev] +panic = "abort" + +[profile.release] +panic = "abort" + +[profile.release-with-debug] +inherits = "release" +debug = true +panic = "abort" + +# Note - This crate gets built during the build.rs script for the +# hyperlight-host crate (aarch64 macOS + `hvf` feature only). Cargo package +# does not allow for files named 'Cargo.toml' to be included in a crate so +# we'll rename during the build.rs script so this file can be included with +# the source for the hvf_surrogate binary in the hyperlight-host crate. diff --git a/src/hyperlight_host/src/hvf_surrogate/build.rs b/src/hyperlight_host/src/hvf_surrogate/build.rs new file mode 100644 index 000000000..246587aaa --- /dev/null +++ b/src/hyperlight_host/src/hvf_surrogate/build.rs @@ -0,0 +1,23 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +fn main() { + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); + if target_os != "macos" || target_arch != "aarch64" { + panic!("hvf_surrogate can only be built for aarch64 macOS targets"); + } +} diff --git a/src/hyperlight_host/src/hvf_surrogate/src/main.rs b/src/hyperlight_host/src/hvf_surrogate/src/main.rs new file mode 100644 index 000000000..23a828cc8 --- /dev/null +++ b/src/hyperlight_host/src/hvf_surrogate/src/main.rs @@ -0,0 +1,440 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! HVF surrogate server. +//! +//! HVF allows only one VM per process, so a host that runs multiple +//! concurrent sandboxes delegates each VM to a surrogate process. This +//! binary is that server: `hyperlight-host` spawns it with an inherited +//! `SOCK_STREAM` unix socket fd in `HVF_SURROGATE_FD` and speaks the +//! [`hyperlight_hvf::proto`] protocol to it. +//! +//! Threading (HVF requires vCPU create/run/register access on a single +//! thread; `hv_vcpus_exit` may be called from any thread): +//! +//! - The **main thread** is the vCPU thread: it owns the VM and executes +//! every stateful request. +//! - A **reader thread** reads request frames from the socket. `Cancel` is +//! handled directly there and gets no response; every other request is +//! forwarded to the main thread. +//! - A **writer thread** writes response frames. Responses are written by +//! exactly one thread, and — unlike folding the writer role into the +//! reader thread — the reader never blocks waiting for a response, so a +//! `Cancel` that arrives while a `RunVcpu` is executing is always seen. +//! +//! Exit codes: `0` on clean EOF (the host went away), `2` on protocol +//! errors (bad handshake, malformed frame, socket failure). + +use std::collections::HashMap; +use std::ffi::{CString, c_void}; +use std::io; +use std::os::unix::io::FromRawFd; +use std::os::unix::net::UnixStream; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{Receiver, Sender, channel}; + +use hyperlight_hvf::core::{self, Perms, Vm}; +use hyperlight_hvf::proto::{self, Backing, PROTO_VERSION, Request, Response}; + +/// Environment variable carrying the inherited control-socket fd. +const FD_ENV_VAR: &str = "HVF_SURROGATE_FD"; + +/// Exit code for protocol errors (bad handshake, malformed frame). +const EXIT_PROTOCOL_ERROR: i32 = 2; + +/// A live `mmap` of a memory object into this process, unmapped on drop. +struct Mapping { + va: usize, + size: usize, +} + +impl Drop for Mapping { + fn drop(&mut self) { + // SAFETY: `va`/`size` describe a live mapping created in + // `map_memory`, and this drop runs at most once. + unsafe { + libc::munmap(self.va as *mut c_void, self.size); + } + } +} + +fn main() { + let Some(fd) = std::env::var(FD_ENV_VAR).ok().and_then(|v| v.parse().ok()) else { + eprintln!("hvf_surrogate: {FD_ENV_VAR} is not set or not a valid fd"); + std::process::exit(EXIT_PROTOCOL_ERROR); + }; + // SAFETY: the parent spawns us with this socket fd open (created + // without CLOEXEC so it survives `exec`). + let socket = unsafe { UnixStream::from_raw_fd(fd) }; + let writer_socket = match socket.try_clone() { + Ok(s) => s, + Err(e) => { + eprintln!("hvf_surrogate: failed to clone socket: {e}"); + std::process::exit(EXIT_PROTOCOL_ERROR); + } + }; + + // Requests (minus Cancel) flow reader -> main; responses flow + // main -> writer. + let (req_tx, req_rx) = channel::(); + let (resp_tx, resp_rx) = channel::(); + + std::thread::spawn(move || writer_thread(writer_socket, resp_rx)); + + // The current vCPU id, shared with the reader thread so it can cancel + // a running vCPU. 0 means "no vCPU". + let vcpu_id = Arc::new(AtomicU64::new(0)); + let reader_vcpu_id = Arc::clone(&vcpu_id); + std::thread::spawn(move || reader_thread(socket, req_tx, reader_vcpu_id)); + + vcpu_thread(req_rx, resp_tx, &vcpu_id); +} + +/// Main-thread loop: owns the VM and executes every stateful request. +fn vcpu_thread(rx: Receiver, resp_tx: Sender, vcpu_id: &AtomicU64) { + let mut vm: Option = None; + let mut mappings: HashMap = HashMap::new(); + while let Ok(req) = rx.recv() { + let resp = handle_request(req, &mut vm, &mut mappings, vcpu_id); + if resp_tx.send(resp).is_err() { + // The writer thread is gone; nothing useful left to do. + std::process::exit(EXIT_PROTOCOL_ERROR); + } + } + // The reader thread exits the whole process on EOF, so reaching this + // point means the channel closed unexpectedly; leave quietly. +} + +/// Reads request frames from the socket. Completes the handshake, then +/// relays requests to the vCPU thread; `Cancel` is handled locally with +/// no response. +fn reader_thread(mut socket: UnixStream, req_tx: Sender, vcpu_id: Arc) { + handshake(&mut socket); + loop { + match proto::read_frame::(&mut socket) { + Ok(Some(Request::Cancel)) => { + let id = vcpu_id.load(Ordering::Acquire); + if id != 0 { + // A stale cancel of an idle/destroyed vCPU fails + // harmlessly (ids are kernel-validated). + let _ = core::cancel(id); + } + } + Ok(Some(req)) => { + if req_tx.send(req).is_err() { + // The vCPU thread is gone. + std::process::exit(0); + } + } + Ok(None) => { + // Clean EOF: the host is gone. + std::process::exit(0); + } + Err(e) => { + eprintln!("hvf_surrogate: malformed frame: {e}"); + std::process::exit(EXIT_PROTOCOL_ERROR); + } + } + } +} + +/// Writes response frames; the only thread that writes to the socket after +/// the handshake (which the reader thread completes before relaying any +/// request). +fn writer_thread(mut socket: UnixStream, rx: Receiver) { + while let Ok(resp) = rx.recv() { + if let Err(e) = proto::write_frame(&mut socket, &resp) { + eprintln!("hvf_surrogate: failed to write response: {e}"); + std::process::exit(EXIT_PROTOCOL_ERROR); + } + } +} + +/// The protocol handshake: the first frame must be `Hello` with a matching +/// protocol version. +fn handshake(socket: &mut UnixStream) { + match proto::read_frame::(socket) { + Ok(Some(Request::Hello { version })) if version == PROTO_VERSION => { + if let Err(e) = proto::write_frame( + socket, + &Response::Hello { + version: PROTO_VERSION, + }, + ) { + eprintln!("hvf_surrogate: failed to write handshake response: {e}"); + std::process::exit(EXIT_PROTOCOL_ERROR); + } + } + Ok(Some(Request::Hello { version })) => { + let _ = proto::write_frame( + socket, + &Response::Err(format!( + "protocol version mismatch: client {version}, server {PROTO_VERSION}" + )), + ); + std::process::exit(EXIT_PROTOCOL_ERROR); + } + Ok(Some(_)) => { + eprintln!("hvf_surrogate: expected Hello as the first frame"); + std::process::exit(EXIT_PROTOCOL_ERROR); + } + Ok(None) => { + // The host went away before the handshake. + std::process::exit(0); + } + Err(e) => { + eprintln!("hvf_surrogate: malformed handshake frame: {e}"); + std::process::exit(EXIT_PROTOCOL_ERROR); + } + } +} + +fn err_no_vm() -> Response { + Response::Err("no VM (CreateVm first)".to_string()) +} + +/// Execute one request on the vCPU thread. +fn handle_request( + req: Request, + vm: &mut Option, + mappings: &mut HashMap, + vcpu_id: &AtomicU64, +) -> Response { + match req { + // A `Hello` after the initial handshake marks a new client session + // (the process was returned to the pool and re-acquired): reset any + // state left over from the previous checkout. A version mismatch + // here is answered with `Err` but keeps the process alive — the + // strict `exit(2)` applies to the initial handshake only. + Request::Hello { version } => { + if let Some(v) = vm.take() { + drop(v); + vcpu_id.store(0, Ordering::Release); + } + mappings.clear(); + if version == PROTO_VERSION { + Response::Hello { + version: PROTO_VERSION, + } + } else { + Response::Err(format!( + "protocol version mismatch: client {version}, server {PROTO_VERSION}" + )) + } + } + Request::Cancel => Response::Err("Cancel is handled out-of-band".to_string()), + Request::CreateVm => { + if vm.is_some() { + return Response::Err("VM already exists".to_string()); + } + match Vm::new() { + Ok(v) => { + vcpu_id.store(v.vcpu_id(), Ordering::Release); + *vm = Some(v); + Response::Ok + } + Err(e) => Response::Err(e.to_string()), + } + } + Request::DestroyVm => { + if let Some(v) = vm.take() { + drop(v); + vcpu_id.store(0, Ordering::Release); + } + // Guest mappings died with the VM; release the process mappings. + mappings.clear(); + Response::Ok + } + Request::MapMemory { + slot, + gpa, + size, + perms, + backing, + } => { + let Some(vm) = vm.as_mut() else { + return err_no_vm(); + }; + map_memory(vm, mappings, slot, gpa, size as usize, perms, backing) + } + Request::UnmapMemory { slot, gpa, size } => { + let Some(vm) = vm.as_mut() else { + return err_no_vm(); + }; + match vm.unmap_memory(slot, gpa, size as usize) { + Ok(()) => { + // Dropping the Mapping munmaps after the guest unmap. + mappings.remove(&slot); + Response::Ok + } + Err(e) => Response::Err(e.to_string()), + } + } + Request::RunVcpu => { + let Some(vm) = vm.as_mut() else { + return err_no_vm(); + }; + match vm.run_vcpu() { + Ok(exit) => Response::Exit(exit), + Err(e) => Response::Err(e.to_string()), + } + } + Request::GetRegs => { + let Some(vm) = vm.as_ref() else { + return err_no_vm(); + }; + vm.regs().map(Response::Regs).unwrap_or_else(err) + } + Request::SetRegs(regs) => { + let Some(vm) = vm.as_ref() else { + return err_no_vm(); + }; + vm.set_regs(®s) + .map(|()| Response::Ok) + .unwrap_or_else(err) + } + Request::GetFpu => { + let Some(vm) = vm.as_ref() else { + return err_no_vm(); + }; + vm.fpu().map(Response::Fpu).unwrap_or_else(err) + } + Request::SetFpu(fpu) => { + let Some(vm) = vm.as_ref() else { + return err_no_vm(); + }; + vm.set_fpu(&fpu).map(|()| Response::Ok).unwrap_or_else(err) + } + Request::GetSregs => { + let Some(vm) = vm.as_ref() else { + return err_no_vm(); + }; + vm.sregs().map(Response::Sregs).unwrap_or_else(err) + } + Request::SetSregs(sregs) => { + let Some(vm) = vm.as_ref() else { + return err_no_vm(); + }; + vm.set_sregs(&sregs) + .map(|()| Response::Ok) + .unwrap_or_else(err) + } + } +} + +fn err(e: core::HvfError) -> Response { + Response::Err(e.to_string()) +} + +/// `mmap` the backing object and map it into the guest, replacing any +/// existing mapping for `slot`. +fn map_memory( + vm: &mut Vm, + mappings: &mut HashMap, + slot: u32, + gpa: u64, + size: usize, + perms: Perms, + backing: Backing, +) -> Response { + let va = match mmap_backing(&backing, size) { + Ok(va) => va, + Err(e) => return Response::Err(format!("failed to map backing object: {e}")), + }; + // SAFETY: [va, va + size) is a live mapping owned by this process; on + // success it is stored in `mappings` and only munmapped after + // `vm.unmap_memory` (or VM destruction). + match unsafe { vm.map_memory(slot, gpa, va, size, perms) } { + Ok(()) => { + // On slot replacement the old Mapping is munmapped here, after + // `map_memory` already unmapped the old slot from the guest. + mappings.insert(slot, Mapping { va, size }); + Response::Ok + } + Err(e) => { + // SAFETY: the mapping was created above and is not in use. + unsafe { + libc::munmap(va as *mut c_void, size); + } + Response::Err(e.to_string()) + } + } +} + +/// `mmap` `size` bytes of the backing object into this process and return +/// the base address. +fn mmap_backing(backing: &Backing, size: usize) -> io::Result { + match backing { + Backing::Shm { name, offset } => { + let c_name = CString::new(name.as_str()) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + // SAFETY: `c_name` is a valid NUL-terminated string. + let fd = unsafe { libc::shm_open(c_name.as_ptr(), libc::O_RDWR, 0) }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + // Unlink immediately: the host keeps its own fd and mapping and + // we now have ours, so the name is no longer needed. Errors are + // ignored (the host also unlinks on drop). + // SAFETY: `c_name` is a valid NUL-terminated string. + unsafe { + libc::shm_unlink(c_name.as_ptr()); + } + // SAFETY: `fd` is a live shm object descriptor and `offset` + // lies within it; the result is checked against MAP_FAILED. + let va = unsafe { + libc::mmap( + std::ptr::null_mut(), + size, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_SHARED, + fd, + *offset as libc::off_t, + ) + }; + // SAFETY: `fd` is a live descriptor no longer needed once the + // mmap exists (or failed). + unsafe { + libc::close(fd); + } + if va == libc::MAP_FAILED { + return Err(io::Error::last_os_error()); + } + Ok(va as usize) + } + Backing::File { path, offset } => { + use std::os::unix::io::AsRawFd; + let file = std::fs::File::open(path)?; + // SAFETY: `file` is a live read-only descriptor and `offset` + // lies within it; the result is checked against MAP_FAILED. + let va = unsafe { + libc::mmap( + std::ptr::null_mut(), + size, + libc::PROT_READ, + libc::MAP_SHARED, + file.as_raw_fd(), + *offset as libc::off_t, + ) + }; + if va == libc::MAP_FAILED { + return Err(io::Error::last_os_error()); + } + Ok(va as usize) + } + } +} diff --git a/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs b/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs new file mode 100644 index 000000000..41b2d0522 --- /dev/null +++ b/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs @@ -0,0 +1,650 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! Pool of HVF surrogate server processes for aarch64 macOS. +//! +//! HVF allows only one VM per process, so each concurrent sandbox delegates +//! its VM to an `hvf_surrogate` server process (see +//! `src/hyperlight_host/src/hvf_surrogate`). This module mirrors the Windows +//! `surrogate_process_manager`: it embeds the surrogate binary (and the +//! hypervisor entitlement plist) via rust-embed, extracts them next to the +//! running executable, ad-hoc codesigns the binary, and hands out pooled +//! processes. Each checked-out [`HvfSurrogateProcess`] owns one end of a +//! socketpair speaking the [`hyperlight_hvf::proto`] protocol; dropping the +//! handle returns a still-alive process to the pool. +//! +//! Sizing is controlled by `HYPERLIGHT_INITIAL_SURROGATES` (default 0 — +//! unlike the suspended Windows shells these are real running server +//! processes, so they are spawned on demand) and `HYPERLIGHT_MAX_SURROGATES` +//! (default 64; `0` disables surrogates entirely, see +//! [`surrogates_disabled`]). + +// Consumed by the surrogate VM backend (a later phase); until then only +// the unit tests use this API. +#![allow(dead_code)] + +use std::os::unix::io::FromRawFd; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, OnceLock}; + +use crossbeam_channel::{Receiver, Sender, TryRecvError, unbounded}; +use rust_embed::RustEmbed; +use tracing::{error, info, warn}; + +use crate::{Result, new_error}; + +// Use the rust-embed crate to embed the hvf_surrogate binary in the +// hyperlight-host library to make dependency management easier. +// $HYPERLIGHT_HVF_SURROGATE_DIR is set by hyperlight-host's build.rs script. +// https://docs.rs/rust-embed/latest/rust_embed/ +#[derive(RustEmbed)] +#[folder = "$HYPERLIGHT_HVF_SURROGATE_DIR"] +#[include = "hvf_surrogate"] +struct Asset; + +/// The name of the embedded surrogate asset (used as the key for `Asset::get`). +const EMBEDDED_SURROGATE_NAME: &str = "hvf_surrogate"; + +/// The entitlements plist embedded in this library; extracted next to the +/// surrogate binary and applied with `codesign` on extraction. The path is +/// set by hyperlight-host's build.rs script. +const ENTITLEMENTS_PLIST: &[u8] = include_bytes!(env!("HYPERLIGHT_HVF_ENTITLEMENTS_PATH")); + +/// Environment variable passed to the surrogate carrying its inherited +/// control-socket fd. +const SURROGATE_FD_ENV_VAR: &str = "HVF_SURROGATE_FD"; + +/// Environment variable controlling how many surrogate processes are +/// pre-created when the manager starts. Must be <= +/// `HYPERLIGHT_MAX_SURROGATES`. Defaults to 0 (created on demand). +const INITIAL_SURROGATES_ENV_VAR: &str = "HYPERLIGHT_INITIAL_SURROGATES"; + +/// Environment variable controlling the maximum number of surrogate +/// processes that can exist (including those created on demand). Must be >= +/// `HYPERLIGHT_INITIAL_SURROGATES`. Defaults to 64. `0` disables +/// surrogates entirely. +const MAX_SURROGATES_ENV_VAR: &str = "HYPERLIGHT_MAX_SURROGATES"; + +/// Default maximum number of surrogate processes (macOS imposes no hard +/// per-process limit like WHP's 512 handles, but each surrogate is a real +/// running process, so keep the default modest). +const DEFAULT_MAX_SURROGATE_PROCESSES: usize = 64; + +/// A pooled surrogate process: the child handle plus our end of the +/// control socketpair. +struct PooledSurrogate { + child: Child, + socket: UnixStream, +} + +/// A surrogate process checked out from the pool. Dropping it returns the +/// process to the pool if it is still alive, and reaps it otherwise. +pub(crate) struct HvfSurrogateProcess { + /// `Some` while checked out; taken by `Drop`. + inner: Option, + /// Channel back to the owning manager's pool. + return_sender: Sender, + /// Shared with the owning manager; decremented when this process dies + /// and is not returned, freeing capacity for a replacement. + created_count: Arc, +} + +impl HvfSurrogateProcess { + /// The control socket connected to the surrogate server (clone it with + /// `try_clone` to share it across threads). + pub(crate) fn socket(&self) -> &UnixStream { + &self + .inner + .as_ref() + .expect("socket is only accessible while checked out") + .socket + } + + /// Returns `true` if the surrogate process is still running. + pub(crate) fn is_alive(&mut self) -> bool { + self.inner + .as_mut() + .is_some_and(|p| matches!(p.child.try_wait(), Ok(None))) + } +} + +impl Drop for HvfSurrogateProcess { + fn drop(&mut self) { + let Some(mut pooled) = self.inner.take() else { + return; + }; + match pooled.child.try_wait() { + Ok(None) => { + // Still alive: return it to the pool. If the manager is + // already gone, kill and reap the process instead. + if let Err(crossbeam_channel::SendError(mut pooled)) = + self.return_sender.send(pooled) + { + let _ = pooled.child.kill(); + let _ = pooled.child.wait(); + } + } + _ => { + // Already exited (reaped by `try_wait`): free up capacity + // for a replacement. + self.created_count.fetch_sub(1, Ordering::AcqRel); + } + } + } +} + +/// `HvfSurrogateProcessManager` manages `hvf_surrogate` processes. These +/// processes are required to run multiple HVF VMs from a single host +/// process: each surrogate owns one VM and is driven over a unix +/// socketpair using the [`hyperlight_hvf::proto`] protocol. +/// +/// This struct deals with the creation/destruction of these processes, the +/// pooling of process handles, the distribution of handles from the pool to +/// a Hyperlight Sandbox instance and the return of a handle to the pool +/// once the Sandbox instance is destroyed. It is intended to be used as a +/// singleton and is thread safe. +/// +/// By default no processes are pre-created; additional processes are +/// created on demand up to `HYPERLIGHT_MAX_SURROGATES` (default 64). If the +/// pool is empty and the max has been reached, callers block until a +/// process is returned. +pub(crate) struct HvfSurrogateProcessManager { + /// `process_receiver` and `process_sender` synchronize reserving a + /// surrogate process from the pool and returning one to the pool. See + /// the Windows `SurrogateProcessManager` for why these are + /// crossbeam-channel types (`Send + Sync`). + process_receiver: Receiver, + process_sender: Sender, + /// Path to the on-disk surrogate binary (hash-stamped). + surrogate_process_path: PathBuf, + /// Maximum number of surrogate processes allowed to exist. + max_processes: usize, + /// Number of surrogate processes created so far (minus those observed + /// dead). Used to decide whether we can spawn more on demand. + created_count: Arc, +} + +impl HvfSurrogateProcessManager { + fn new() -> Result { + let binary_name = surrogate_binary_name()?; + ensure_surrogate_exe(&binary_name)?; + let surrogate_process_path = get_surrogate_process_dir()?.join(&binary_name); + + let (initial, max) = surrogate_process_counts(); + + let (sender, receiver) = unbounded(); + let manager = HvfSurrogateProcessManager { + process_receiver: receiver, + process_sender: sender, + surrogate_process_path, + max_processes: max, + created_count: Arc::new(AtomicUsize::new(0)), + }; + + info!( + "pre-creating {} hvf surrogate processes ({}={:?}, {}={:?})", + initial, + INITIAL_SURROGATES_ENV_VAR, + std::env::var(INITIAL_SURROGATES_ENV_VAR).ok(), + MAX_SURROGATES_ENV_VAR, + std::env::var(MAX_SURROGATES_ENV_VAR).ok(), + ); + for _ in 0..initial { + let pooled = spawn_surrogate_process(&manager.surrogate_process_path)?; + manager + .process_sender + .send(pooled) + .map_err(|e| new_error!("surrogate process channel disconnected: {}", e))?; + manager.created_count.fetch_add(1, Ordering::AcqRel); + } + + Ok(manager) + } + + /// Gets a surrogate process from the pool. If the pool is empty and + /// fewer than `max_processes` have been created, a new process is + /// spawned on demand. If the pool is empty and the maximum has been + /// reached, this call blocks until a process is returned. Dead pooled + /// processes are reaped and skipped. + pub(crate) fn get_surrogate_process(&self) -> Result { + // Fast path: try to grab an already-pooled process. + loop { + match self.process_receiver.try_recv() { + Ok(mut pooled) => { + if matches!(pooled.child.try_wait(), Ok(None)) { + return Ok(self.wrap(pooled)); + } + // Dead (reaped by `try_wait`): free the slot and look + // for another pooled process. + self.created_count.fetch_sub(1, Ordering::AcqRel); + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + return Err(new_error!("surrogate process channel disconnected")); + } + } + } + + // On-demand growth: atomically claim a slot if one is available. + // We use a CAS loop so that concurrent callers don't overshoot + // the maximum. + loop { + let current = self.created_count.load(Ordering::Acquire); + if current >= self.max_processes { + // At the limit — fall through to the blocking recv below. + break; + } + if self + .created_count + .compare_exchange(current, current + 1, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + info!( + "on-demand hvf surrogate process creation ({}/{})", + current + 1, + self.max_processes + ); + match spawn_surrogate_process(&self.surrogate_process_path) { + Ok(pooled) => return Ok(self.wrap(pooled)), + Err(e) => { + // Rollback the slot claim so capacity isn't + // permanently lost on transient failures. + self.created_count.fetch_sub(1, Ordering::AcqRel); + return Err(e); + } + } + } + // CAS failed — another thread beat us; retry. + } + + // Maximum reached — block until a process is returned to the pool. + loop { + let mut pooled = self + .process_receiver + .recv() + .map_err(|e| new_error!("surrogate process channel disconnected: {}", e))?; + if matches!(pooled.child.try_wait(), Ok(None)) { + return Ok(self.wrap(pooled)); + } + self.created_count.fetch_sub(1, Ordering::AcqRel); + } + } + + /// Wrap a pooled process in a checkout handle that returns itself to + /// this pool on drop. + fn wrap(&self, pooled: PooledSurrogate) -> HvfSurrogateProcess { + HvfSurrogateProcess { + inner: Some(pooled), + return_sender: self.process_sender.clone(), + created_count: Arc::clone(&self.created_count), + } + } +} + +impl Drop for HvfSurrogateProcessManager { + fn drop(&mut self) { + // Kill and reap every pooled process. Checked-out processes are + // handled by their own drop. + while let Ok(mut pooled) = self.process_receiver.try_recv() { + if let Err(e) = pooled.child.kill() { + error!("failed to kill hvf surrogate process: {}", e); + } + let _ = pooled.child.wait(); + } + } +} + +/// Gets the singleton `HvfSurrogateProcessManager`. +pub(crate) fn get_hvf_surrogate_process_manager() -> Result<&'static HvfSurrogateProcessManager> { + static MANAGER: OnceLock> = + OnceLock::new(); + MANAGER + .get_or_init(|| { + HvfSurrogateProcessManager::new().map_err(|e| { + error!("Failed to create HvfSurrogateProcessManager: {:?}", e); + format!("{e}") + }) + }) + .as_ref() + .map_err(|e| new_error!("Failed to get HvfSurrogateProcessManager: {}", e)) +} + +/// Returns `true` when `HYPERLIGHT_MAX_SURROGATES=0`, meaning surrogate +/// processes are disabled and the direct in-process HVF backend +/// (single-VM-per-process mode) should be used instead. +/// +/// The result is cached on first call — the env var is read only once. +pub(crate) fn surrogates_disabled() -> bool { + static DISABLED: OnceLock = OnceLock::new(); + *DISABLED.get_or_init(|| { + std::env::var(MAX_SURROGATES_ENV_VAR) + .ok() + .and_then(|v| v.parse::().ok()) + .is_some_and(|n| n == 0) + }) +} + +/// Returns the on-disk filename for the surrogate binary, incorporating the +/// first 8 hex characters of the BLAKE3 hash of the embedded binary and the +/// entitlements plist, so that different hyperlight versions produce +/// different filenames and can coexist without file-deletion races. +fn surrogate_binary_name() -> Result { + let exe = Asset::get(EMBEDDED_SURROGATE_NAME) + .ok_or_else(|| new_error!("could not find embedded hvf surrogate binary"))?; + let mut hasher = blake3::Hasher::new(); + hasher.update(exe.data.as_ref()); + hasher.update(ENTITLEMENTS_PLIST); + let short_hash = &hasher.finalize().to_hex()[..8]; + Ok(format!("hvf_surrogate_{short_hash}")) +} + +/// Pure validation/clamping logic for surrogate process counts. +/// +/// `raw_initial` and `raw_max` are the parsed values from the environment +/// (or `None` when the variable is unset or unparsable). +/// +/// Resolution order: +/// 1. `max` defaults to [`DEFAULT_MAX_SURROGATE_PROCESSES`] when `None`. +/// 2. `initial` is clamped to `0..=max`, defaulting to 0 when `None`. +/// +/// When `max == 0`, surrogates are disabled entirely and the system falls +/// back to the direct in-process HVF backend. +fn compute_surrogate_counts(raw_initial: Option, raw_max: Option) -> (usize, usize) { + let max = raw_max.unwrap_or(DEFAULT_MAX_SURROGATE_PROCESSES); + + // Clamp initial to 0..=max so it can never exceed the authoritative limit. + let initial = raw_initial.map(|n| n.clamp(0, max)).unwrap_or(0); + + (initial, max) +} + +/// Returns the (initial, max) surrogate process counts from environment +/// variables, applying validation and clamping. +/// +/// - `HYPERLIGHT_INITIAL_SURROGATES`: clamped to `0..=max`, default 0. +/// - `HYPERLIGHT_MAX_SURROGATES`: default 64; `0` disables surrogates. +fn surrogate_process_counts() -> (usize, usize) { + let raw_initial = std::env::var(INITIAL_SURROGATES_ENV_VAR) + .ok() + .and_then(|v| v.parse::().ok()); + let raw_max = std::env::var(MAX_SURROGATES_ENV_VAR) + .ok() + .and_then(|v| v.parse::().ok()); + + let (initial, max) = compute_surrogate_counts(raw_initial, raw_max); + + if let Some(n) = raw_initial + && n != initial + { + warn!("{INITIAL_SURROGATES_ENV_VAR}={n} was clamped to {initial}"); + } + if let Some(n) = raw_max + && n != max + { + warn!("{MAX_SURROGATES_ENV_VAR}={n} was clamped to {max}"); + } + + (initial, max) +} + +fn get_surrogate_process_dir() -> Result { + let binding = std::env::current_exe()?; + let path = binding + .parent() + .ok_or_else(|| new_error!("could not get parent directory of current executable"))?; + + Ok(path.to_path_buf()) +} + +/// Ensures the surrogate binary exists on disk at the hash-stamped path, +/// executable and ad-hoc codesigned with the hypervisor entitlement +/// (without it `hv_vm_create` fails in the surrogate). +/// +/// The filename embeds the content hash of the binary and the plist, and +/// extraction writes to a pid-stamped temporary file that is atomically +/// renamed into place after signing, so any visible hash-stamped file is +/// complete and signed. +fn ensure_surrogate_exe(binary_name: &str) -> Result<()> { + let dir = get_surrogate_process_dir()?; + let final_path = dir.join(binary_name); + if final_path.exists() { + return Ok(()); + } + + let exe = Asset::get(EMBEDDED_SURROGATE_NAME) + .ok_or_else(|| new_error!("could not find embedded hvf surrogate binary"))?; + + let tmp_path = dir.join(format!(".{binary_name}.tmp-{}", std::process::id())); + let plist_path = dir.join(format!("{binary_name}.entitlements.plist")); + + let result = (|| -> Result<()> { + std::fs::write(&tmp_path, exe.data.as_ref())?; + std::fs::set_permissions( + &tmp_path, + ::from_mode(0o755), + )?; + std::fs::write(&plist_path, ENTITLEMENTS_PLIST)?; + let status = Command::new("codesign") + .arg("--sign") + .arg("-") + .arg("--entitlements") + .arg(&plist_path) + .arg("--force") + .arg(&tmp_path) + .status()?; + if !status.success() { + return Err(new_error!("codesign of hvf surrogate failed: {}", status)); + } + info!( + "extracted and codesigned hvf surrogate to {}", + final_path.display() + ); + match std::fs::rename(&tmp_path, &final_path) { + Ok(()) => Ok(()), + // Lost a race with a concurrent extractor; the winner's file + // is content-identical. + Err(_) if final_path.exists() => Ok(()), + Err(e) => Err(e.into()), + } + })(); + + if result.is_err() { + let _ = std::fs::remove_file(&tmp_path); + } + result +} + +/// Spawns a surrogate process with one end of a fresh socketpair inherited +/// as its control socket. +fn spawn_surrogate_process(exe_path: &Path) -> Result { + let mut fds = [0i32; 2]; + // SAFETY: `fds` is a valid two-element array; on success both + // descriptors are owned by us. No SOCK_CLOEXEC: the child end must + // survive the exec of the surrogate binary. + if unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, fds.as_mut_ptr()) } != 0 { + return Err(std::io::Error::last_os_error().into()); + } + let (parent_fd, child_fd) = (fds[0], fds[1]); + + // Mark our end close-on-exec: without this the surrogate would inherit + // a duplicate of the parent's end too, and would then never see EOF + // when the parent closes its end (its own duplicate keeps the socket + // open). + // SAFETY: `parent_fd` is a live descriptor owned by us. + if unsafe { libc::fcntl(parent_fd, libc::F_SETFD, libc::FD_CLOEXEC) } != 0 { + let err = std::io::Error::last_os_error(); + // SAFETY: both descriptors are live and owned by us. + unsafe { + libc::close(parent_fd); + libc::close(child_fd); + } + return Err(err.into()); + } + + let spawn_result = Command::new(exe_path) + .env(SURROGATE_FD_ENV_VAR, child_fd.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .spawn(); + + // The child (if any) has inherited its end by now; the parent never + // uses it again. + // SAFETY: `child_fd` is a live descriptor owned by us. + unsafe { + libc::close(child_fd); + } + + let child = match spawn_result { + Ok(child) => child, + Err(e) => { + // SAFETY: `parent_fd` is a live descriptor owned by us. + unsafe { + libc::close(parent_fd); + } + return Err(e.into()); + } + }; + + // SAFETY: `parent_fd` is a live socket descriptor owned by us. + let socket = unsafe { UnixStream::from_raw_fd(parent_fd) }; + Ok(PooledSurrogate { child, socket }) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use hyperlight_hvf::proto::{PROTO_VERSION, Request, Response, read_frame, write_frame}; + + use super::*; + + fn handshake(sock: &UnixStream) { + let mut s = sock; + write_frame( + &mut s, + &Request::Hello { + version: PROTO_VERSION, + }, + ) + .unwrap(); + match read_frame::(&mut s).unwrap() { + Some(Response::Hello { version }) => assert_eq!(version, PROTO_VERSION), + other => panic!("unexpected handshake response: {other:?}"), + } + } + + fn request(sock: &UnixStream, req: &Request) -> Response { + let mut s = sock; + write_frame(&mut s, req).unwrap(); + read_frame::(&mut s) + .unwrap() + .expect("surrogate closed the connection") + } + + fn connect(sock: &UnixStream) -> UnixStream { + let s = sock.try_clone().unwrap(); + s.set_read_timeout(Some(Duration::from_secs(30))).unwrap(); + s + } + + /// Smoke test: acquire a surrogate, complete the handshake, create a + /// VM, round-trip the system registers with a known value, destroy the + /// VM, and return the process to the pool — then verify the pooled + /// process is reused for another full VM lifecycle. + /// + /// The test binary must be codesigned with + /// `dev/hvf-entitlements.plist` (see the `test-hvf` just recipe). + #[test] + fn surrogate_handshake_and_sregs_round_trip() { + let mgr = get_hvf_surrogate_process_manager().unwrap(); + assert!(!surrogates_disabled()); + + let mut proc = mgr.get_surrogate_process().unwrap(); + assert!(proc.is_alive()); + let sock = connect(proc.socket()); + + handshake(&sock); + assert!(matches!(request(&sock, &Request::CreateVm), Response::Ok)); + + // Round-trip sregs with a known value. + let Response::Sregs(mut sregs) = request(&sock, &Request::GetSregs) else { + panic!("expected Sregs"); + }; + sregs.sp_el1 = 0x1234_5000; + assert!(matches!( + request(&sock, &Request::SetSregs(sregs)), + Response::Ok + )); + let Response::Sregs(got) = request(&sock, &Request::GetSregs) else { + panic!("expected Sregs"); + }; + assert_eq!(got, sregs); + + assert!(matches!(request(&sock, &Request::DestroyVm), Response::Ok)); + + // Return the process to the pool; re-acquiring must yield a live, + // reusable surrogate. + drop(proc); + let mut proc2 = mgr.get_surrogate_process().unwrap(); + assert!(proc2.is_alive()); + let sock2 = connect(proc2.socket()); + handshake(&sock2); + assert!(matches!(request(&sock2, &Request::CreateVm), Response::Ok)); + assert!(matches!(request(&sock2, &Request::DestroyVm), Response::Ok)); + } + + /// Verifies `compute_surrogate_counts()` returns sensible defaults + /// when inputs are `None`, and correct clamped values otherwise. + #[test] + fn test_compute_surrogate_counts() { + // Both unset → defaults (initial 0, max 64). + let (initial, max) = compute_surrogate_counts(None, None); + assert_eq!(initial, 0); + assert_eq!(max, DEFAULT_MAX_SURROGATE_PROCESSES); + + // Only initial set → honoured, clamped to max. + let (initial, max) = compute_surrogate_counts(Some(8), None); + assert_eq!(initial, 8); + assert_eq!(max, DEFAULT_MAX_SURROGATE_PROCESSES); + + // Initial above max → clamped down to max. + let (initial, max) = compute_surrogate_counts(Some(100), Some(10)); + assert_eq!(initial, 10); + assert_eq!(max, 10); + + // Max at zero → surrogates disabled, initial follows. + let (initial, max) = compute_surrogate_counts(None, Some(0)); + assert_eq!(initial, 0); + assert_eq!(max, 0); + + // Both set, max > initial. + let (initial, max) = compute_surrogate_counts(Some(3), Some(7)); + assert_eq!(initial, 3); + assert_eq!(max, 7); + } + + /// Verifies extraction naming is deterministic. + #[test] + fn test_surrogate_binary_name_deterministic() { + let a = surrogate_binary_name().unwrap(); + let b = surrogate_binary_name().unwrap(); + assert_eq!(a, b); + assert!(a.starts_with("hvf_surrogate_")); + } +} diff --git a/src/hyperlight_host/src/hypervisor/mod.rs b/src/hyperlight_host/src/hypervisor/mod.rs index da4acc22b..394f382d4 100644 --- a/src/hyperlight_host/src/hypervisor/mod.rs +++ b/src/hyperlight_host/src/hypervisor/mod.rs @@ -33,6 +33,10 @@ pub(crate) mod surrogate_process_manager; #[cfg(target_os = "windows")] pub mod wrappers; +#[cfg(hvf)] +/// Pool of HVF surrogate server processes (one HVF VM per process) +pub(crate) mod hvf_surrogate_manager; + #[cfg(crashdump)] pub(crate) mod crashdump; From 27cc900088c0243d881d9989abf72597113cad13 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Wed, 22 Jul 2026 17:23:05 +0100 Subject: [PATCH 07/14] feat(host): run HVF sandboxes in surrogate processes Multi-sandbox support for macOS: each sandbox's VM now lives in a pooled surrogate process, with the host acting as an IPC client. - HvfSurrogateVm implements the VirtualMachine trait by forwarding over the proto socket (hvf.rs split into hvf/{mod,direct,surrogate}.rs). - MacOSInterruptHandle generalizes to Local (hv_vcpus_exit) and Remote (Cancel frame over a mutex-shared socket writer) targets. - HyperlightVm::new selects direct vs surrogate mode; HYPERLIGHT_MAX_SURROGATES=0 selects direct mode with a clean single-VM error via a process-global NoSurrogateGuard. - File-backed guest regions carry their path through PreparedFileMapping so the surrogate can mmap them (MAP_PRIVATE read-only: hv_vm_map rejects read-only MAP_SHARED). - Fixes uncovered by the newly-enabled parallel suite: * surrogate Cancel never fired (vCPU id 0 collided with the no-vCPU sentinel; store id+1), with a protocol-level regression test; * surrogate must not shm_unlink after open (broke snapshot remaps); the host owns the name lifecycle; * implement reset_vcpu for both backends (needed by snapshot restore), via a new proto request. - Full unit suite without --test-threads=1: 268 passed / 11 failed (pre-S3: 100/175). The remaining 11 are a pre-existing 4K-vs-16K page-size assumption cluster in the map_region/mmap tests, unrelated to HVF. Signed-off-by: Sienna Meridian Satterwhite --- .../src/hvf_surrogate/src/main.rs | 37 ++- .../src/hypervisor/hvf_surrogate_manager.rs | 118 ++++++- .../src/hypervisor/hyperlight_vm/aarch64.rs | 84 +++-- src/hyperlight_host/src/hypervisor/mod.rs | 57 +++- .../virtual_machine/{hvf.rs => hvf/direct.rs} | 161 ++++------ .../src/hypervisor/virtual_machine/hvf/mod.rs | 144 +++++++++ .../virtual_machine/hvf/surrogate.rs | 304 ++++++++++++++++++ .../src/hypervisor/virtual_machine/mod.rs | 9 +- src/hyperlight_host/src/mem/memory_region.rs | 34 +- src/hyperlight_host/src/mem/shared_mem.rs | 1 + .../src/sandbox/file_mapping.rs | 7 + .../src/sandbox/snapshot/file_tests.rs | 3 +- src/hyperlight_hvf/src/core.rs | 30 +- src/hyperlight_hvf/src/proto.rs | 15 +- 14 files changed, 815 insertions(+), 189 deletions(-) rename src/hyperlight_host/src/hypervisor/virtual_machine/{hvf.rs => hvf/direct.rs} (58%) create mode 100644 src/hyperlight_host/src/hypervisor/virtual_machine/hvf/mod.rs create mode 100644 src/hyperlight_host/src/hypervisor/virtual_machine/hvf/surrogate.rs diff --git a/src/hyperlight_host/src/hvf_surrogate/src/main.rs b/src/hyperlight_host/src/hvf_surrogate/src/main.rs index 23a828cc8..0fe41568d 100644 --- a/src/hyperlight_host/src/hvf_surrogate/src/main.rs +++ b/src/hyperlight_host/src/hvf_surrogate/src/main.rs @@ -95,8 +95,9 @@ fn main() { std::thread::spawn(move || writer_thread(writer_socket, resp_rx)); - // The current vCPU id, shared with the reader thread so it can cancel - // a running vCPU. 0 means "no vCPU". + // The current vCPU id **plus one**, shared with the reader thread so it + // can cancel a running vCPU. 0 means "no vCPU" (HVF vCPU ids are + // zero-based, so the raw id cannot double as the sentinel). let vcpu_id = Arc::new(AtomicU64::new(0)); let reader_vcpu_id = Arc::clone(&vcpu_id); std::thread::spawn(move || reader_thread(socket, req_tx, reader_vcpu_id)); @@ -131,7 +132,7 @@ fn reader_thread(mut socket: UnixStream, req_tx: Sender, vcpu_id: Arc { @@ -243,7 +244,9 @@ fn handle_request( } match Vm::new() { Ok(v) => { - vcpu_id.store(v.vcpu_id(), Ordering::Release); + // +1: vCPU ids are zero-based, 0 is the "no vCPU" + // sentinel. + vcpu_id.store(v.vcpu_id() + 1, Ordering::Release); *vm = Some(v); Response::Ok } @@ -333,6 +336,12 @@ fn handle_request( .map(|()| Response::Ok) .unwrap_or_else(err) } + Request::ResetVcpu => { + let Some(vm) = vm.as_ref() else { + return err_no_vm(); + }; + vm.reset_vcpu().map(|()| Response::Ok).unwrap_or_else(err) + } } } @@ -387,13 +396,12 @@ fn mmap_backing(backing: &Backing, size: usize) -> io::Result { if fd < 0 { return Err(io::Error::last_os_error()); } - // Unlink immediately: the host keeps its own fd and mapping and - // we now have ours, so the name is no longer needed. Errors are - // ignored (the host also unlinks on drop). - // SAFETY: `c_name` is a valid NUL-terminated string. - unsafe { - libc::shm_unlink(c_name.as_ptr()); - } + // NOTE: the name is NOT unlinked here. The host owns the + // object's name lifecycle (it unlinks when its own backing + // drops) and may legitimately ask us to map the same object + // again (e.g. snapshot restore remaps); unlinking at map time + // would make those later `shm_open`s fail with ENOENT. Our + // mmap keeps the object alive even after the name is gone. // SAFETY: `fd` is a live shm object descriptor and `offset` // lies within it; the result is checked against MAP_FAILED. let va = unsafe { @@ -419,6 +427,11 @@ fn mmap_backing(backing: &Backing, size: usize) -> io::Result { Backing::File { path, offset } => { use std::os::unix::io::AsRawFd; let file = std::fs::File::open(path)?; + // MAP_PRIVATE on purpose: `hv_vm_map` rejects read-only + // MAP_SHARED file mappings (measured: HV error 0xfae94001), + // while MAP_PRIVATE works and matches the host's own view of + // the file. These regions are read-only+exec, so the + // copy-on-read private view is coherent. // SAFETY: `file` is a live read-only descriptor and `offset` // lies within it; the result is checked against MAP_FAILED. let va = unsafe { @@ -426,7 +439,7 @@ fn mmap_backing(backing: &Backing, size: usize) -> io::Result { std::ptr::null_mut(), size, libc::PROT_READ, - libc::MAP_SHARED, + libc::MAP_PRIVATE, file.as_raw_fd(), *offset as libc::off_t, ) diff --git a/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs b/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs index 41b2d0522..938cc3e50 100644 --- a/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs +++ b/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs @@ -32,10 +32,6 @@ limitations under the License. //! (default 64; `0` disables surrogates entirely, see //! [`surrogates_disabled`]). -// Consumed by the surrogate VM backend (a later phase); until then only -// the unit tests use this API. -#![allow(dead_code)] - use std::os::unix::io::FromRawFd; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; @@ -117,6 +113,7 @@ impl HvfSurrogateProcess { } /// Returns `true` if the surrogate process is still running. + #[allow(dead_code)] // used by tests; kept for diagnostics pub(crate) fn is_alive(&mut self) -> bool { self.inner .as_mut() @@ -563,6 +560,119 @@ mod tests { s } + /// Protocol-level cancellation probe: map a shm region containing an + /// infinite loop (`b .`), run the vCPU, and cancel it from another + /// thread. The run must exit with `VmExit::Cancelled`. + #[test] + fn surrogate_cancel_interrupts_run() { + use std::ffi::CString; + + use hyperlight_hvf::core::{Perms, Regs, VmExit}; + use hyperlight_hvf::proto::Backing; + + let mgr = get_hvf_surrogate_process_manager().unwrap(); + let mut proc = mgr.get_surrogate_process().unwrap(); + let sock = connect(proc.socket()); + handshake(&sock); + assert!(matches!(request(&sock, &Request::CreateVm), Response::Ok)); + + // Guest memory: one page with `b .` (0x14000000) at the start. + const SIZE: usize = 0x4000; + const GPA: u64 = 0x4000_0000; + let name = format!("/hl-{}-cancel", std::process::id()); + let c_name = CString::new(name.as_str()).unwrap(); + // SAFETY: `c_name` is a valid NUL-terminated string; flags/mode are + // valid. On success the fd is ours. + let fd = unsafe { + libc::shm_open( + c_name.as_ptr(), + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL, + 0o600, + ) + }; + assert!( + fd >= 0, + "shm_open failed: {:?}", + std::io::Error::last_os_error() + ); + // SAFETY: `fd` is a live shm descriptor we just created. + assert_eq!(unsafe { libc::ftruncate(fd, SIZE as libc::off_t) }, 0); + // SAFETY: `fd` is a live shm descriptor of SIZE bytes. + let va = unsafe { + libc::mmap( + std::ptr::null_mut(), + SIZE, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_SHARED, + fd, + 0, + ) + }; + assert_ne!(va, libc::MAP_FAILED); + // `b .` — branch to self, an infinite loop. + // SAFETY: `va` points to a live writable mapping of SIZE bytes. + unsafe { (va as *mut u32).write(0x1400_0000) }; + + assert!(matches!( + request( + &sock, + &Request::MapMemory { + slot: 0, + gpa: GPA, + size: SIZE as u64, + perms: Perms { + read: true, + write: false, + exec: true, + }, + backing: Backing::Shm { + name: name.clone(), + offset: 0, + }, + } + ), + Response::Ok + )); + + // Run with MMU off (fresh vCPU: SCTLR_EL1 reset value), PC at GPA, + // EL1t with interrupts masked — same pstate the host uses. + let regs = Regs { + pc: GPA, + pstate: 0b11 << 6 | 0b100, + ..Default::default() + }; + assert!(matches!( + request(&sock, &Request::SetRegs(regs)), + Response::Ok + )); + + // Cancel from another thread after 200ms (a cloned fd, mirroring + // the interrupt handle's shared writer). + let cancel_sock = sock.try_clone().unwrap(); + let cancel_thread = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(200)); + let mut s = &cancel_sock; + write_frame(&mut s, &Request::Cancel).unwrap(); + }); + + let resp = request(&sock, &Request::RunVcpu); + cancel_thread.join().unwrap(); + assert!( + matches!(resp, Response::Exit(VmExit::Cancelled)), + "expected Exit(Cancelled), got {resp:?}" + ); + + assert!(matches!(request(&sock, &Request::DestroyVm), Response::Ok)); + + // SAFETY: `va`/`fd`/`name` are the live mapping, descriptor and + // object created above. + unsafe { + libc::munmap(va, SIZE); + libc::close(fd); + libc::shm_unlink(c_name.as_ptr()); + } + } + /// Smoke test: acquire a surrogate, complete the handshake, create a /// VM, round-trip the system registers with a known value, destroy the /// VM, and return the process to the pool — then verify the pooled diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs index f3e2039de..67c33446b 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs @@ -29,14 +29,14 @@ use super::{ use crate::hypervisor::InterruptHandleImpl; #[cfg(any(kvm, mshv3))] use crate::hypervisor::LinuxInterruptHandle; -#[cfg(hvf)] -use crate::hypervisor::MacOSInterruptHandle; #[cfg(gdb)] use crate::hypervisor::gdb::{DebugCommChannel, DebugMsg, DebugResponse}; +#[cfg(hvf)] +use crate::hypervisor::hvf_surrogate_manager; use crate::hypervisor::hyperlight_vm::get_guest_log_filter; use crate::hypervisor::regs::{CommonFpu, CommonRegisters, CommonSpecialRegisters}; #[cfg(hvf)] -use crate::hypervisor::virtual_machine::hvf::HvfVm; +use crate::hypervisor::virtual_machine::hvf::{HvfSurrogateVm, HvfVm}; #[cfg(kvm)] use crate::hypervisor::virtual_machine::kvm::KvmVm; #[cfg(any(kvm, mshv3, hvf))] @@ -44,6 +44,8 @@ use crate::hypervisor::virtual_machine::{HypervisorType, VmError}; use crate::hypervisor::virtual_machine::{ RegisterError, ResetVcpuError, VirtualMachine, get_available_hypervisor, }; +#[cfg(hvf)] +use crate::hypervisor::{HvfInterruptTarget, MacOSInterruptHandle}; use crate::mem::mgr::{SandboxMemoryManager, SnapshotSharedMemory}; use crate::mem::shared_mem::{GuestSharedMemory, HostSharedMemory}; use crate::sandbox::SandboxConfiguration; @@ -70,36 +72,62 @@ impl HyperlightVm { ) -> std::result::Result { // TODO: support gdb on aarch64 type VmType = Box; - let vm: VmType = match get_available_hypervisor() { - #[cfg(kvm)] - Some(HypervisorType::Kvm) => Box::new(KvmVm::new().map_err(VmError::CreateVm)?), - // TODO: mshv support - #[cfg(mshv3)] - Some(HypervisorType::Mshv) => return Err(CreateHyperlightVmError::NoHypervisorFound), - #[cfg(hvf)] - Some(HypervisorType::Hvf) => Box::new(HvfVm::new().map_err(VmError::CreateVm)?), - None => return Err(CreateHyperlightVmError::NoHypervisorFound), - }; - vm.set_sregs(&CommonSpecialRegisters::defaults(root_pt_addr)) - .map_err(VmError::Register)?; #[cfg(any(kvm, mshv3))] - let interrupt_handle: Arc = Arc::new(LinuxInterruptHandle { - state: AtomicU8::new(0), - tid: AtomicU64::new(unsafe { libc::pthread_self() as u64 }), - retry_delay: config.get_interrupt_retry_delay(), - sig_rt_min_offset: config.get_interrupt_vcpu_sigrtmin_offset(), - dropped: AtomicBool::new(false), - }); + let (vm, interrupt_handle): (VmType, Arc) = { + let vm: VmType = match get_available_hypervisor() { + #[cfg(kvm)] + Some(HypervisorType::Kvm) => Box::new(KvmVm::new().map_err(VmError::CreateVm)?), + // TODO: mshv support + #[cfg(mshv3)] + Some(HypervisorType::Mshv) => { + return Err(CreateHyperlightVmError::NoHypervisorFound); + } + None => return Err(CreateHyperlightVmError::NoHypervisorFound), + }; + let interrupt_handle: Arc = Arc::new(LinuxInterruptHandle { + state: AtomicU8::new(0), + tid: AtomicU64::new(unsafe { libc::pthread_self() as u64 }), + retry_delay: config.get_interrupt_retry_delay(), + sig_rt_min_offset: config.get_interrupt_vcpu_sigrtmin_offset(), + dropped: AtomicBool::new(false), + }); + (vm, interrupt_handle) + }; #[cfg(hvf)] - let interrupt_handle: Arc = { + let (vm, interrupt_handle): (VmType, Arc) = { // `config` only carries Linux signal settings today. let _ = config; - Arc::new(MacOSInterruptHandle { - state: AtomicU8::new(0), - vcpu: vm.vcpu_id(), - dropped: AtomicBool::new(false), - }) + match get_available_hypervisor() { + Some(HypervisorType::Hvf) => { + if hvf_surrogate_manager::surrogates_disabled() { + // Direct in-process backend: single VM per process. + let vm = HvfVm::new().map_err(VmError::CreateVm)?; + let interrupt_handle: Arc = + Arc::new(MacOSInterruptHandle { + state: AtomicU8::new(0), + target: HvfInterruptTarget::Local(vm.vcpu_id()), + dropped: AtomicBool::new(false), + }); + (Box::new(vm) as VmType, interrupt_handle) + } else { + // Surrogate backend: the VM lives in a pooled + // surrogate process; cancellation goes over the + // shared socket writer. + let vm = HvfSurrogateVm::new().map_err(VmError::CreateVm)?; + let interrupt_handle: Arc = + Arc::new(MacOSInterruptHandle { + state: AtomicU8::new(0), + target: HvfInterruptTarget::Remote(vm.cancel_writer()), + dropped: AtomicBool::new(false), + }); + (Box::new(vm) as VmType, interrupt_handle) + } + } + None => return Err(CreateHyperlightVmError::NoHypervisorFound), + } }; + vm.set_sregs(&CommonSpecialRegisters::defaults(root_pt_addr)) + .map_err(VmError::Register)?; let snapshot_slot = 0u32; let scratch_slot = 1u32; diff --git a/src/hyperlight_host/src/hypervisor/mod.rs b/src/hyperlight_host/src/hypervisor/mod.rs index 394f382d4..31cd97071 100644 --- a/src/hyperlight_host/src/hypervisor/mod.rs +++ b/src/hyperlight_host/src/hypervisor/mod.rs @@ -43,12 +43,16 @@ pub(crate) mod crashdump; pub(crate) mod hyperlight_vm; use std::fmt::Debug; +#[cfg(hvf)] +use std::os::unix::net::UnixStream; #[cfg(any(kvm, mshv3))] use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering}; #[cfg(any(target_os = "windows", hvf))] use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; #[cfg(target_os = "windows")] use std::sync::atomic::{AtomicU8, Ordering}; +#[cfg(hvf)] +use std::sync::{Arc, Mutex}; #[cfg(any(kvm, mshv3))] use std::time::Duration; @@ -471,24 +475,37 @@ pub(super) struct MacOSInterruptHandle { /// - Bit 1: RUNNING_BIT - set when vcpu is actively running /// - Bit 0: CANCEL_BIT - set when cancellation has been requested /// - /// `hv_vcpus_exit()` forces an immediate exit of a running vCPU and is a - /// no-op-safe call otherwise, but we still track the RUNNING_BIT to + /// Cancelling forces an immediate exit of a running vCPU and is a + /// no-op-safe operation otherwise, but we still track the RUNNING_BIT to /// report whether the vCPU was actually interrupted. /// /// CANCEL_BIT persists across vcpu exits/re-entries within a single `VirtualCPU::run()` call /// (e.g., during host function calls), but is cleared at the start of each new `VirtualCPU::run()` call. pub(super) state: AtomicU8, - /// The HVF vCPU ID to interrupt. `hv_vcpus_exit` is documented as - /// callable from any thread, and vCPU IDs are kernel-validated (a stale - /// cancel of a destroyed vCPU simply fails), so no lock is needed to - /// guard against concurrent vCPU destruction. - pub(super) vcpu: u64, + /// How to reach the vCPU to cancel a run. + pub(super) target: HvfInterruptTarget, /// Whether the corresponding VM has been dropped. pub(super) dropped: AtomicBool, } +/// How a [`MacOSInterruptHandle`] reaches the vCPU to cancel a run. +#[cfg(hvf)] +#[derive(Debug)] +pub(super) enum HvfInterruptTarget { + /// Direct mode: the vCPU lives in this process and is cancelled with + /// `hv_vcpus_exit`. The call is documented as callable from any thread, + /// and vCPU IDs are kernel-validated (a stale cancel of a destroyed + /// vCPU simply fails), so no lock is needed to guard against concurrent + /// vCPU destruction. + Local(u64), + /// Surrogate mode: the vCPU lives in a surrogate process and is + /// cancelled by sending a `Cancel` frame over the shared writer (the + /// same mutex serializes request frames, so frames cannot interleave). + Remote(Arc>), +} + #[cfg(hvf)] impl MacOSInterruptHandle { const RUNNING_BIT: u8 = 1 << 1; @@ -508,12 +525,26 @@ impl MacOSInterruptHandle { return false; } - // SAFETY: `hv_vcpus_exit` may be called from any thread, and the - // vCPU ID is kernel-validated, so a stale cancel after the vCPU was - // destroyed fails harmlessly. - unsafe { - applevisor_sys::hv_vcpus_exit(&self.vcpu, 1) - == applevisor_sys::hv_error_t::HV_SUCCESS as i32 + match &self.target { + HvfInterruptTarget::Local(vcpu) => { + // SAFETY: `hv_vcpus_exit` may be called from any thread, and + // the vCPU ID is kernel-validated, so a stale cancel after + // the vCPU was destroyed fails harmlessly. + unsafe { + applevisor_sys::hv_vcpus_exit(vcpu, 1) + == applevisor_sys::hv_error_t::HV_SUCCESS as i32 + } + } + HvfInterruptTarget::Remote(writer) => { + let Ok(mut writer) = writer.lock() else { + return false; + }; + hyperlight_hvf::proto::write_frame( + &mut *writer, + &hyperlight_hvf::proto::Request::Cancel, + ) + .is_ok() + } } } } diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/hvf.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/direct.rs similarity index 58% rename from src/hyperlight_host/src/hypervisor/virtual_machine/hvf.rs rename to src/hyperlight_host/src/hypervisor/virtual_machine/hvf/direct.rs index 8b40485a9..59e7feda3 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/hvf.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/direct.rs @@ -14,123 +14,51 @@ See the License for the specific language governing permissions and limitations under the License. */ -//! Direct HVF (Hypervisor.framework) backend for macOS on aarch64. +//! Direct (in-process) HVF backend. //! -//! This is the single-VM-per-process backend: HVF binds a VM to its creating -//! process, so only one sandbox backed by [`HvfVm`] can exist in a process at -//! a time. Multi-sandbox support uses the surrogate-process backend instead -//! (see `super::hvf_surrogate`), which delegates each sandbox's VM to a -//! helper process. The actual HVF logic lives in the shared -//! [`hyperlight_hvf::core`] crate; this file adapts it to the -//! [`VirtualMachine`] trait. +//! Used when surrogates are disabled (`HYPERLIGHT_MAX_SURROGATES=0`). Only +//! one [`HvfVm`] can exist per process — HVF's one-VM-per-process limit — +//! which is enforced by [`NoSurrogateGuard`] so a second VM fails with a +//! clean error instead of a raw `HV_BUSY`. -use hyperlight_hvf::core::{self, FpuState, HvfError, Perms, Regs, Sregs}; -use tracing::{Span, instrument}; +use std::sync::atomic::{AtomicBool, Ordering}; + +use hyperlight_hvf::core::{self, HvfError, Perms}; use crate::hypervisor::regs::{CommonFpu, CommonRegisters, CommonSpecialRegisters}; use crate::hypervisor::virtual_machine::{ - CreateVmError, HypervisorError, MapMemoryError, RegisterError, RunVcpuError, UnmapMemoryError, + CreateVmError, MapMemoryError, RegisterError, ResetVcpuError, RunVcpuError, UnmapMemoryError, VirtualMachine, VmExit, }; +#[cfg(feature = "trace_guest")] +use crate::sandbox::trace::TraceContext as SandboxTraceContext; -/// Return `true` if Hypervisor.framework is available. -/// -/// Requires the calling binary to hold the `com.apple.security.hypervisor` -/// entitlement; unsigned test binaries must be ad-hoc codesigned with it. -#[instrument(skip_all, parent = Span::current(), level = "Trace")] -pub(crate) fn is_hypervisor_present() -> bool { - core::is_hypervisor_present() -} - -impl From for HypervisorError { - fn from(e: HvfError) -> Self { - match e { - HvfError::Hv(code) => HypervisorError::HvfError(code), - HvfError::NoInstructionSyndrome => HypervisorError::HvfError(0), - } - } -} - -impl From for CommonRegisters { - fn from(r: Regs) -> Self { - CommonRegisters { - x: r.x, - sp: r.sp, - pc: r.pc, - pstate: r.pstate, - } - } -} - -impl From<&CommonRegisters> for Regs { - fn from(r: &CommonRegisters) -> Self { - Regs { - x: r.x, - sp: r.sp, - pc: r.pc, - pstate: r.pstate, - } - } -} - -impl From for CommonFpu { - fn from(f: FpuState) -> Self { - CommonFpu { - v: f.v, - fpsr: f.fpsr, - fpcr: f.fpcr, - } - } -} +/// Set while a direct-mode VM exists in this process; cleared when the +/// [`NoSurrogateGuard`] stored on the [`HvfVm`] is dropped. +static DIRECT_VM_ACTIVE: AtomicBool = AtomicBool::new(false); -impl From<&CommonFpu> for FpuState { - fn from(f: &CommonFpu) -> Self { - FpuState { - v: f.v, - fpsr: f.fpsr, - fpcr: f.fpcr, +/// RAII guard that sets [`DIRECT_VM_ACTIVE`] on creation and clears it on +/// drop. Stored as a field on [`HvfVm`] so the flag stays set for exactly +/// the lifetime of the VM (mirrors WHP's `NoSurrogateGuard`). +#[derive(Debug)] +struct NoSurrogateGuard; + +impl NoSurrogateGuard { + fn acquire() -> std::result::Result { + if DIRECT_VM_ACTIVE.swap(true, Ordering::SeqCst) { + return Err(CreateVmError::SurrogateProcess( + "HYPERLIGHT_MAX_SURROGATES=0 limits the process to a single VM; \ + a VM is already active" + .into(), + )); } + Ok(Self) } } -impl From for CommonSpecialRegisters { - fn from(s: Sregs) -> Self { - CommonSpecialRegisters { - ttbr0_el1: s.ttbr0_el1, - tcr_el1: s.tcr_el1, - mair_el1: s.mair_el1, - sctlr_el1: s.sctlr_el1, - cpacr_el1: s.cpacr_el1, - vbar_el1: s.vbar_el1, - sp_el1: s.sp_el1, - } - } -} - -impl From<&CommonSpecialRegisters> for Sregs { - fn from(s: &CommonSpecialRegisters) -> Self { - Sregs { - ttbr0_el1: s.ttbr0_el1, - tcr_el1: s.tcr_el1, - mair_el1: s.mair_el1, - sctlr_el1: s.sctlr_el1, - cpacr_el1: s.cpacr_el1, - vbar_el1: s.vbar_el1, - sp_el1: s.sp_el1, - } - } -} - -impl From for VmExit { - fn from(e: core::VmExit) -> Self { - match e { - core::VmExit::Halt => VmExit::Halt(), - core::VmExit::IoOut(port, data) => VmExit::IoOut(port, data), - core::VmExit::MmioRead(addr) => VmExit::MmioRead(addr), - core::VmExit::MmioWrite(addr) => VmExit::MmioWrite(addr), - core::VmExit::Cancelled => VmExit::Cancelled(), - core::VmExit::Unknown(msg) => VmExit::Unknown(msg), - } +impl Drop for NoSurrogateGuard { + fn drop(&mut self) { + DIRECT_VM_ACTIVE.store(false, Ordering::SeqCst); } } @@ -138,14 +66,24 @@ impl From for VmExit { #[derive(Debug)] pub(crate) struct HvfVm { inner: core::Vm, + /// Clears [`DIRECT_VM_ACTIVE`] when this VM is dropped. + _no_surrogate_guard: NoSurrogateGuard, } impl HvfVm { pub(crate) fn new() -> std::result::Result { + let guard = NoSurrogateGuard::acquire()?; Ok(Self { inner: core::Vm::new().map_err(|e| CreateVmError::CreateVmFd(e.into()))?, + _no_surrogate_guard: guard, }) } + + /// The raw HVF vCPU ID, used to construct the local interrupt handle + /// (`hv_vcpus_exit` may be called from any thread). + pub(crate) fn vcpu_id(&self) -> u64 { + self.inner.vcpu_id() + } } impl VirtualMachine for HvfVm { @@ -195,6 +133,8 @@ impl VirtualMachine for HvfVm { &mut self, #[cfg(feature = "trace_guest")] tc: &mut SandboxTraceContext, ) -> std::result::Result { + #[cfg(feature = "trace_guest")] + let _ = tc; match self.inner.run_vcpu() { Ok(exit) => Ok(exit.into()), Err(HvfError::NoInstructionSyndrome) => Err(RunVcpuError::ParseGpaAccessInfo), @@ -254,7 +194,16 @@ impl VirtualMachine for HvfVm { todo!("debug registers are not supported on aarch64") } - fn vcpu_id(&self) -> u64 { - self.inner.vcpu_id() + fn can_reset_vcpu(&self) -> bool { + true + } + + fn reset_vcpu(&mut self) -> std::result::Result<(), ResetVcpuError> { + // HVF has no "vcpu init" operation like KVM's KVM_ARM_VCPU_INIT; + // `core::Vm::reset_vcpu` emulates it. Special registers are + // applied separately by the caller (`apply_sregs`). + self.inner + .reset_vcpu() + .map_err(|e| ResetVcpuError::Hypervisor(e.into())) } } diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/mod.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/mod.rs new file mode 100644 index 000000000..059f52fa0 --- /dev/null +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/mod.rs @@ -0,0 +1,144 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! HVF (Hypervisor.framework) backends for macOS on aarch64. +//! +//! Two [`VirtualMachine`] implementations exist: +//! +//! - [`direct::HvfVm`]: the single-VM-per-process backend — HVF binds a VM +//! to its creating process, so only one sandbox backed by it can exist in +//! a process at a time. Used when surrogates are disabled +//! (`HYPERLIGHT_MAX_SURROGATES=0`). +//! - [`surrogate::HvfSurrogateVm`]: delegates each sandbox's VM to a +//! surrogate helper process over the [`hyperlight_hvf::proto`] IPC +//! protocol, allowing multiple concurrent sandboxes. +//! +//! The actual HVF logic lives in the shared [`hyperlight_hvf::core`] crate; +//! this module adapts it to the [`VirtualMachine`] trait and hosts the +//! register/exit conversions shared by both backends. + +use hyperlight_hvf::core::{self, FpuState, HvfError, Regs, Sregs}; +use tracing::{Span, instrument}; + +use crate::hypervisor::regs::{CommonFpu, CommonRegisters, CommonSpecialRegisters}; +use crate::hypervisor::virtual_machine::{HypervisorError, VmExit}; + +pub(crate) mod direct; +pub(crate) mod surrogate; + +pub(crate) use direct::HvfVm; +pub(crate) use surrogate::HvfSurrogateVm; + +/// Return `true` if Hypervisor.framework is available. +/// +/// Requires the calling binary to hold the `com.apple.security.hypervisor` +/// entitlement; unsigned test binaries must be ad-hoc codesigned with it. +#[instrument(skip_all, parent = Span::current(), level = "Trace")] +pub(crate) fn is_hypervisor_present() -> bool { + core::is_hypervisor_present() +} + +impl From for HypervisorError { + fn from(e: HvfError) -> Self { + match e { + HvfError::Hv(code) => HypervisorError::HvfError(code), + HvfError::NoInstructionSyndrome => HypervisorError::HvfError(0), + } + } +} + +impl From for CommonRegisters { + fn from(r: Regs) -> Self { + CommonRegisters { + x: r.x, + sp: r.sp, + pc: r.pc, + pstate: r.pstate, + } + } +} + +impl From<&CommonRegisters> for Regs { + fn from(r: &CommonRegisters) -> Self { + Regs { + x: r.x, + sp: r.sp, + pc: r.pc, + pstate: r.pstate, + } + } +} + +impl From for CommonFpu { + fn from(f: FpuState) -> Self { + CommonFpu { + v: f.v, + fpsr: f.fpsr, + fpcr: f.fpcr, + } + } +} + +impl From<&CommonFpu> for FpuState { + fn from(f: &CommonFpu) -> Self { + FpuState { + v: f.v, + fpsr: f.fpsr, + fpcr: f.fpcr, + } + } +} + +impl From for CommonSpecialRegisters { + fn from(s: Sregs) -> Self { + CommonSpecialRegisters { + ttbr0_el1: s.ttbr0_el1, + tcr_el1: s.tcr_el1, + mair_el1: s.mair_el1, + sctlr_el1: s.sctlr_el1, + cpacr_el1: s.cpacr_el1, + vbar_el1: s.vbar_el1, + sp_el1: s.sp_el1, + } + } +} + +impl From<&CommonSpecialRegisters> for Sregs { + fn from(s: &CommonSpecialRegisters) -> Self { + Sregs { + ttbr0_el1: s.ttbr0_el1, + tcr_el1: s.tcr_el1, + mair_el1: s.mair_el1, + sctlr_el1: s.sctlr_el1, + cpacr_el1: s.cpacr_el1, + vbar_el1: s.vbar_el1, + sp_el1: s.sp_el1, + } + } +} + +impl From for VmExit { + fn from(e: core::VmExit) -> Self { + match e { + core::VmExit::Halt => VmExit::Halt(), + core::VmExit::IoOut(port, data) => VmExit::IoOut(port, data), + core::VmExit::MmioRead(addr) => VmExit::MmioRead(addr), + core::VmExit::MmioWrite(addr) => VmExit::MmioWrite(addr), + core::VmExit::Cancelled => VmExit::Cancelled(), + core::VmExit::Unknown(msg) => VmExit::Unknown(msg), + } + } +} diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/surrogate.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/surrogate.rs new file mode 100644 index 000000000..544962b98 --- /dev/null +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/surrogate.rs @@ -0,0 +1,304 @@ +/* +Copyright 2026 The Hyperlight Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! Surrogate-process HVF backend. +//! +//! HVF allows only one VM per process, so this backend delegates the VM to +//! an `hvf_surrogate` server process (acquired from the +//! [`HvfSurrogateProcessManager`] pool) and forwards every +//! [`VirtualMachine`] operation over the [`hyperlight_hvf::proto`] IPC +//! protocol. +//! +//! Socket discipline: the sandbox thread performs request→response cycles; +//! the interrupt thread may send `Request::Cancel` concurrently. All frames +//! (requests AND cancels) are written through one shared +//! `Arc>` writer — `proto::write_frame` serializes each +//! frame into a single buffer, so frames cannot interleave on the wire. +//! Responses are read on a separate stream clone used only by the sandbox +//! thread (`Cancel` has no response). + +use std::fmt; +use std::os::unix::net::UnixStream; +use std::sync::{Arc, Mutex}; + +use hyperlight_hvf::core::Perms; +use hyperlight_hvf::proto::{self, PROTO_VERSION, Request, Response}; + +use crate::hypervisor::hvf_surrogate_manager::{ + HvfSurrogateProcess, get_hvf_surrogate_process_manager, +}; +use crate::hypervisor::regs::{CommonFpu, CommonRegisters, CommonSpecialRegisters}; +use crate::hypervisor::virtual_machine::{ + CreateVmError, HypervisorError, MapMemoryError, RegisterError, ResetVcpuError, RunVcpuError, + UnmapMemoryError, VirtualMachine, VmExit, +}; +#[cfg(feature = "trace_guest")] +use crate::sandbox::trace::TraceContext as SandboxTraceContext; + +/// A [`VirtualMachine`] whose VM lives in a surrogate process. +pub(crate) struct HvfSurrogateVm { + /// The pooled surrogate process; held for its `Drop`, which returns a + /// still-running process to the pool. + _process: HvfSurrogateProcess, + /// Shared writer for ALL frames (requests from the sandbox thread and + /// cancels from the interrupt thread). Also handed to the interrupt + /// handle via [`HvfSurrogateVm::cancel_writer`]. + writer: Arc>, + /// Reader for responses; only the sandbox thread performs + /// request→response cycles, but the trait takes `&self` for register + /// reads, hence the mutex. + reader: Mutex, +} + +// The fields are all Send + Sync; `fmt::Debug` is implemented manually +// because `HvfSurrogateProcess` has no Debug impl. +impl fmt::Debug for HvfSurrogateVm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HvfSurrogateVm").finish_non_exhaustive() + } +} + +impl HvfSurrogateVm { + /// Acquire a surrogate process from the pool, complete the protocol + /// handshake, and create its VM. + pub(crate) fn new() -> std::result::Result { + let process = get_hvf_surrogate_process_manager() + .and_then(|m| m.get_surrogate_process()) + .map_err(|e| CreateVmError::SurrogateProcess(format!("{e}")))?; + let reader = process + .socket() + .try_clone() + .map_err(|e| CreateVmError::SurrogateProcess(format!("failed to clone socket: {e}")))?; + let writer = process + .socket() + .try_clone() + .map_err(|e| CreateVmError::SurrogateProcess(format!("failed to clone socket: {e}")))?; + + let vm = Self { + _process: process, + writer: Arc::new(Mutex::new(writer)), + reader: Mutex::new(reader), + }; + + match vm.request(&Request::Hello { + version: PROTO_VERSION, + }) { + Ok(Response::Hello { version }) if version == PROTO_VERSION => {} + other => { + return Err(CreateVmError::SurrogateProcess(format!( + "surrogate handshake failed: {other:?}" + ))); + } + } + vm.request(&Request::CreateVm) + .and_then(expect_ok) + .map_err(CreateVmError::CreateVmFd)?; + Ok(vm) + } + + /// The shared frame writer, handed to the interrupt handle so it can + /// send `Request::Cancel` concurrently with in-flight requests. + pub(crate) fn cancel_writer(&self) -> Arc> { + Arc::clone(&self.writer) + } + + /// Send a request and wait for its response. + fn request(&self, req: &Request) -> std::result::Result { + { + let mut writer = self.writer.lock().map_err(|_| { + HypervisorError::HvfSurrogateError("surrogate socket lock poisoned".into()) + })?; + proto::write_frame(&mut *writer, req).map_err(|e| { + HypervisorError::HvfSurrogateError(format!("failed to send request: {e}")) + })?; + } + let mut reader = self.reader.lock().map_err(|_| { + HypervisorError::HvfSurrogateError("surrogate socket lock poisoned".into()) + })?; + proto::read_frame::(&mut *reader) + .map_err(|e| { + HypervisorError::HvfSurrogateError(format!("failed to read response: {e}")) + })? + .ok_or_else(|| { + HypervisorError::HvfSurrogateError("surrogate closed the connection".into()) + }) + } +} + +impl Drop for HvfSurrogateVm { + fn drop(&mut self) { + // Best-effort: destroy the VM so the surrogate returns to its idle + // state and can be reused by another sandbox. The response is read + // to keep the stream in sync for the next client of this pooled + // process. + let _ = self.request(&Request::DestroyVm); + } +} + +/// Extract `Response::Ok`, mapping `Err`/unexpected responses to a +/// [`HypervisorError`]. +fn expect_ok(resp: Response) -> std::result::Result<(), HypervisorError> { + match resp { + Response::Ok => Ok(()), + Response::Err(e) => Err(HypervisorError::HvfSurrogateError(e)), + other => Err(HypervisorError::HvfSurrogateError(format!( + "unexpected response: {other:?}" + ))), + } +} + +impl VirtualMachine for HvfSurrogateVm { + unsafe fn map_memory( + &mut self, + (slot, region): (u32, &crate::mem::memory_region::MemoryRegion), + ) -> std::result::Result<(), MapMemoryError> { + let perms = Perms { + read: region + .flags + .contains(crate::mem::memory_region::MemoryRegionFlags::READ), + write: region + .flags + .contains(crate::mem::memory_region::MemoryRegionFlags::WRITE), + exec: region + .flags + .contains(crate::mem::memory_region::MemoryRegionFlags::EXECUTE), + }; + let (backing, size, gpa) = region.surrogate_backing(); + self.request(&Request::MapMemory { + slot, + gpa, + size, + perms, + backing, + }) + .and_then(expect_ok) + .map_err(MapMemoryError::Hypervisor) + } + + fn unmap_memory( + &mut self, + (slot, region): (u32, &crate::mem::memory_region::MemoryRegion), + ) -> std::result::Result<(), UnmapMemoryError> { + self.request(&Request::UnmapMemory { + slot, + gpa: region.guest_region.start as u64, + size: (region.guest_region.end - region.guest_region.start) as u64, + }) + .and_then(expect_ok) + .map_err(UnmapMemoryError::Hypervisor) + } + + fn run_vcpu( + &mut self, + #[cfg(feature = "trace_guest")] tc: &mut SandboxTraceContext, + ) -> std::result::Result { + #[cfg(feature = "trace_guest")] + let _ = tc; + match self.request(&Request::RunVcpu) { + Ok(Response::Exit(exit)) => Ok(exit.into()), + Ok(Response::Err(e)) => { + Err(RunVcpuError::Unknown(HypervisorError::HvfSurrogateError(e))) + } + Ok(other) => Err(RunVcpuError::Unknown(HypervisorError::HvfSurrogateError( + format!("unexpected response: {other:?}"), + ))), + Err(e) => Err(RunVcpuError::Unknown(e)), + } + } + + fn regs(&self) -> std::result::Result { + match self.request(&Request::GetRegs) { + Ok(Response::Regs(regs)) => Ok(regs.into()), + Ok(Response::Err(e)) => Err(RegisterError::GetRegs( + HypervisorError::HvfSurrogateError(e), + )), + Ok(other) => Err(RegisterError::GetRegs(HypervisorError::HvfSurrogateError( + format!("unexpected response: {other:?}"), + ))), + Err(e) => Err(RegisterError::GetRegs(e)), + } + } + + fn set_regs(&self, regs: &CommonRegisters) -> std::result::Result<(), RegisterError> { + self.request(&Request::SetRegs(regs.into())) + .and_then(expect_ok) + .map_err(RegisterError::SetRegs) + } + + fn fpu(&self) -> std::result::Result { + match self.request(&Request::GetFpu) { + Ok(Response::Fpu(fpu)) => Ok(fpu.into()), + Ok(Response::Err(e)) => { + Err(RegisterError::GetFpu(HypervisorError::HvfSurrogateError(e))) + } + Ok(other) => Err(RegisterError::GetFpu(HypervisorError::HvfSurrogateError( + format!("unexpected response: {other:?}"), + ))), + Err(e) => Err(RegisterError::GetFpu(e)), + } + } + + fn set_fpu(&self, fpu: &CommonFpu) -> std::result::Result<(), RegisterError> { + self.request(&Request::SetFpu(fpu.into())) + .and_then(expect_ok) + .map_err(RegisterError::SetFpu) + } + + fn sregs(&self) -> std::result::Result { + match self.request(&Request::GetSregs) { + Ok(Response::Sregs(sregs)) => Ok(sregs.into()), + Ok(Response::Err(e)) => Err(RegisterError::GetSregs( + HypervisorError::HvfSurrogateError(e), + )), + Ok(other) => Err(RegisterError::GetSregs(HypervisorError::HvfSurrogateError( + format!("unexpected response: {other:?}"), + ))), + Err(e) => Err(RegisterError::GetSregs(e)), + } + } + + fn set_sregs(&self, sregs: &CommonSpecialRegisters) -> std::result::Result<(), RegisterError> { + self.request(&Request::SetSregs(sregs.into())) + .and_then(expect_ok) + .map_err(RegisterError::SetSregs) + } + + fn debug_regs( + &self, + ) -> std::result::Result { + todo!("debug registers are not supported on aarch64") + } + + fn set_debug_regs( + &self, + _drs: &crate::hypervisor::regs::CommonDebugRegs, + ) -> std::result::Result<(), RegisterError> { + todo!("debug registers are not supported on aarch64") + } + + fn can_reset_vcpu(&self) -> bool { + true + } + + fn reset_vcpu(&mut self) -> std::result::Result<(), ResetVcpuError> { + // HVF has no "vcpu init" operation like KVM's KVM_ARM_VCPU_INIT; + // the surrogate emulates it (`core::Vm::reset_vcpu`). Special + // registers are applied separately by the caller (`apply_sregs`). + self.request(&Request::ResetVcpu) + .and_then(expect_ok) + .map_err(ResetVcpuError::Hypervisor) + } +} diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs index ba4175743..24fa33229 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs @@ -197,7 +197,7 @@ pub enum CreateVmError { InitializeVm(HypervisorError), #[error("Set Partition Property failed: {0}")] SetPartitionProperty(HypervisorError), - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", hvf))] #[error("Surrogate process creation failed: {0}")] SurrogateProcess(String), } @@ -320,6 +320,9 @@ pub enum HypervisorError { #[cfg(hvf)] #[error("HVF error: {0:#x}")] HvfError(u32), + #[cfg(hvf)] + #[error("HVF surrogate error: {0}")] + HvfSurrogateError(String), } /// Trait for single-vCPU VMs. Provides a common interface for basic VM operations. @@ -398,10 +401,6 @@ pub(crate) trait VirtualMachine: Debug + Send { /// Get partition handle #[cfg(target_os = "windows")] fn partition_handle(&self) -> windows::Win32::System::Hypervisor::WHV_PARTITION_HANDLE; - /// Get the HVF vCPU ID, used to construct the interrupt handle - /// (`hv_vcpus_exit` may be called from any thread) - #[cfg(hvf)] - fn vcpu_id(&self) -> u64; } #[cfg(test)] diff --git a/src/hyperlight_host/src/mem/memory_region.rs b/src/hyperlight_host/src/mem/memory_region.rs index fb086cd1b..b7b48f9d6 100644 --- a/src/hyperlight_host/src/mem/memory_region.rs +++ b/src/hyperlight_host/src/mem/memory_region.rs @@ -291,12 +291,17 @@ impl MemoryRegionKind for HostGuestMemoryRegion { #[cfg(target_os = "macos")] #[derive(Debug, PartialEq, Eq, Clone, Hash)] pub struct HostRegionBase { - /// Name of the backing POSIX shm object, as passed to `shm_open` + /// Name of the backing POSIX shm object, as passed to `shm_open`. + /// Empty for file-backed regions (see `path`). pub name: String, - /// Offset of the region start within the shm object + /// Offset of the region start within the shm object or file pub offset: usize, /// Host virtual address of the region start pub base: usize, + /// For file-backed regions ([`MemoryRegionType::MappedFile`]): the + /// filesystem path the surrogate process maps read-only instead of a + /// shm object. + pub path: Option, } #[cfg(target_os = "macos")] impl From for usize { @@ -313,6 +318,7 @@ impl MemoryRegionKind for HostGuestMemoryRegion { name: base.name, offset: base.offset + size, base: base.base + size, + path: base.path, } } } @@ -349,19 +355,27 @@ pub type MemoryRegion = MemoryRegion_; #[cfg(hvf)] impl MemoryRegion { - /// Extract the surrogate-IPC view of this region: the shm backing - /// (object name + offset of the region within the object), the - /// region size, and its guest physical address. + /// Extract the surrogate-IPC view of this region: the memory object + /// backing it (a POSIX shm object name, or a filesystem path for + /// file-backed regions), the region size, and its guest physical + /// address. /// /// Used when delegating the mapping to the HVF surrogate process; /// see [`hyperlight_hvf::proto::Request::MapMemory`]. - #[allow(dead_code)] // consumed by the surrogate-process backend (a later phase) pub(crate) fn surrogate_backing(&self) -> (hyperlight_hvf::proto::Backing, u64, u64) { - ( - hyperlight_hvf::proto::Backing::Shm { - name: self.host_region.start.name.clone(), - offset: self.host_region.start.offset as u64, + let start = &self.host_region.start; + let backing = match &start.path { + Some(path) => hyperlight_hvf::proto::Backing::File { + path: path.clone(), + offset: start.offset as u64, + }, + None => hyperlight_hvf::proto::Backing::Shm { + name: start.name.clone(), + offset: start.offset as u64, }, + }; + ( + backing, (self.guest_region.end - self.guest_region.start) as u64, self.guest_region.start as u64, ) diff --git a/src/hyperlight_host/src/mem/shared_mem.rs b/src/hyperlight_host/src/mem/shared_mem.rs index 837844157..952fc990e 100644 --- a/src/hyperlight_host/src/mem/shared_mem.rs +++ b/src/hyperlight_host/src/mem/shared_mem.rs @@ -665,6 +665,7 @@ pub trait SharedMemory { name: self.region().shm.name.clone(), offset: guard_page_size(), base: self.base_addr(), + path: None, } } #[cfg(windows)] diff --git a/src/hyperlight_host/src/sandbox/file_mapping.rs b/src/hyperlight_host/src/sandbox/file_mapping.rs index 60e0e8b74..bbe50e120 100644 --- a/src/hyperlight_host/src/sandbox/file_mapping.rs +++ b/src/hyperlight_host/src/sandbox/file_mapping.rs @@ -62,6 +62,10 @@ pub(crate) struct PreparedFileMapping { /// Host-side OS resources. `None` after successful consumption /// by the apply step (ownership transferred to the VM layer). pub(crate) host_resources: Option, + /// The path of the mapped file. Retained on macOS so the HVF + /// surrogate process can map the same file read-only by path. + #[cfg(target_os = "macos")] + pub(crate) path: std::path::PathBuf, } /// Platform-specific host-side file mapping resources. @@ -210,6 +214,7 @@ impl PreparedFileMapping { name: String::new(), offset: 0, base: *mmap_base as usize, + path: Some(self.path.clone()), }; let host_end = ::add(host_base.clone(), *mmap_size); @@ -384,6 +389,8 @@ pub(crate) fn prepare_file_cow(file_path: &Path, guest_base: u64) -> Result &'static str { "kvm" => "kvm", "mshv" => "mshv", "whp" => "whp", + "hvf" => "hvf", other => panic!("unknown hypervisor tag {other}"), } } @@ -1045,7 +1046,7 @@ fn save_appends_into_existing_layout_with_new_tag() { ); let hv = anns["dev.hyperlight.snapshot.hypervisor"].as_str().unwrap(); assert!( - ["kvm", "mshv", "whp"].contains(&hv), + ["kvm", "mshv", "whp", "hvf"].contains(&hv), "unexpected hypervisor annotation: {}", hv ); diff --git a/src/hyperlight_hvf/src/core.rs b/src/hyperlight_hvf/src/core.rs index bd8884a9c..1c27dba7e 100644 --- a/src/hyperlight_hvf/src/core.rs +++ b/src/hyperlight_hvf/src/core.rs @@ -313,7 +313,8 @@ impl Vm { let mut exit: *const hv_vcpu_exit_t = std::ptr::null(); // SAFETY: `vcpu` and `exit` are valid out-pointers; a null config // creates a vCPU with the default configuration on the current thread. - if let Err(e) = check_hv(unsafe { hv_vcpu_create(&mut vcpu, &mut exit, std::ptr::null_mut()) }) + if let Err(e) = + check_hv(unsafe { hv_vcpu_create(&mut vcpu, &mut exit, std::ptr::null_mut()) }) { // Don't leak the process-wide VM on failure. // SAFETY: the VM was just created above and has no vCPUs. @@ -479,12 +480,7 @@ impl Vm { check_hv(unsafe { hv_vcpu_get_reg(self.vcpu, hv_reg_t::PC, &mut pc) })?; let mut pstate = 0; check_hv(unsafe { hv_vcpu_get_reg(self.vcpu, hv_reg_t::CPSR, &mut pstate) })?; - Ok(Regs { - x, - sp, - pc, - pstate, - }) + Ok(Regs { x, sp, pc, pstate }) } /// Write the general-purpose registers. @@ -565,6 +561,26 @@ impl Vm { set(hv_sys_reg_t::SP_EL1, sregs.sp_el1)?; Ok(()) } + + /// Reset the vCPU to a clean state, emulating KVM's `KVM_ARM_VCPU_INIT` + /// (HVF has no equivalent operation): GP and FP registers are zeroed, + /// and the debug breakpoint/watchpoint pair 0 is cleared so no stale + /// breakpoints survive a snapshot restore. Only pair 0 is cleared — + /// Hyperlight guests use no other debug registers. Translation system + /// registers (TCR/TTBR/…) are NOT touched; the caller applies them + /// from the snapshot after the reset. + pub fn reset_vcpu(&self) -> Result<(), HvfError> { + self.set_regs(&Regs::default())?; + self.set_fpu(&FpuState::default())?; + // SAFETY: called on the vCPU thread. + unsafe { + check_hv(hv_vcpu_set_sys_reg(self.vcpu, hv_sys_reg_t::DBGBVR0_EL1, 0))?; + check_hv(hv_vcpu_set_sys_reg(self.vcpu, hv_sys_reg_t::DBGBCR0_EL1, 0))?; + check_hv(hv_vcpu_set_sys_reg(self.vcpu, hv_sys_reg_t::DBGWVR0_EL1, 0))?; + check_hv(hv_vcpu_set_sys_reg(self.vcpu, hv_sys_reg_t::DBGWCR0_EL1, 0))?; + } + Ok(()) + } } impl Drop for Vm { diff --git a/src/hyperlight_hvf/src/proto.rs b/src/hyperlight_hvf/src/proto.rs index 56b8e7fe2..266dd15a9 100644 --- a/src/hyperlight_hvf/src/proto.rs +++ b/src/hyperlight_hvf/src/proto.rs @@ -115,6 +115,11 @@ pub enum Request { GetSregs, /// Write the system registers. Responds with [`Response::Ok`]. SetSregs(Sregs), + /// Reset the vCPU to a clean state (GP/FP registers zeroed, debug + /// breakpoint/watchpoint pair 0 cleared). Responds with + /// [`Response::Ok`]. Translation system registers are untouched; the + /// client applies them from the snapshot afterwards. + ResetVcpu, /// Force the running vCPU out of `hv_vcpu_run`. May be sent while a /// [`Request::RunVcpu`] is outstanding; receives no response. Cancel, @@ -142,15 +147,19 @@ pub enum Response { Err(String), } -/// Write one length-prefixed frame. +/// Write one length-prefixed frame. The whole frame is serialized into a +/// single buffer and written with one `write_all`, so that frames written +/// from different threads sharing one writer cannot interleave on the wire. pub fn write_frame(w: &mut impl Write, msg: &T) -> io::Result<()> { let body = serde_json::to_vec(msg).map_err(io::Error::other)?; let len: u32 = body .len() .try_into() .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "frame too large"))?; - w.write_all(&len.to_le_bytes())?; - w.write_all(&body)?; + let mut frame = Vec::with_capacity(4 + body.len()); + frame.extend_from_slice(&len.to_le_bytes()); + frame.extend_from_slice(&body); + w.write_all(&frame)?; w.flush() } From 26e13a0947b0092fe897e30019ec3ee3fa0d1aed Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Wed, 22 Jul 2026 18:12:26 +0100 Subject: [PATCH 08/14] test(host): enable integration tests and goldens for hvf - snapshot_goldens/platform.rs: add the Hypervisor::Hvf platform arm (aarch64-apple); v1.0-aarch64-hvf-apple goldens generated and all 5 verification cases pass locally (distributed via the golden registry like the other platforms). - hyperlight_libc/build.rs: use llvm-ar/llvm-ranlib on macOS hosts; Xcode ar silently drops ELF members, producing an empty libhyperlight_libc.a and failing C guest links. - integration_test.rs: macOS arm of the HostRegionBase compile guard; cap interrupt_infinite_moving_loop_stress_test at 48 threads on macOS (HVF allows 127 concurrent VMs system-wide, measured). - hvf_surrogate_manager: default pool max 128 (was 64) against the measured system cap; pool backpressure near the cap is by design. Integration binaries all green locally: integration_test 36/0, sandbox_host_tests 14/0, wit_test 43/0. Signed-off-by: Sienna Meridian Satterwhite --- .../src/hypervisor/hvf_surrogate_manager.rs | 17 +++++++----- src/hyperlight_host/tests/integration_test.rs | 27 ++++++++++++++++++- .../tests/snapshot_goldens/platform.rs | 23 +++++++++++++--- src/hyperlight_libc/build.rs | 27 +++++++++++++++++++ 4 files changed, 82 insertions(+), 12 deletions(-) diff --git a/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs b/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs index 938cc3e50..5a87e5c40 100644 --- a/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs +++ b/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs @@ -29,8 +29,8 @@ limitations under the License. //! Sizing is controlled by `HYPERLIGHT_INITIAL_SURROGATES` (default 0 — //! unlike the suspended Windows shells these are real running server //! processes, so they are spawned on demand) and `HYPERLIGHT_MAX_SURROGATES` -//! (default 64; `0` disables surrogates entirely, see -//! [`surrogates_disabled`]). +//! (default 128, matched to the platform's concurrent-VM cap; `0` disables +//! surrogates entirely, see [`surrogates_disabled`]). use std::os::unix::io::FromRawFd; use std::os::unix::net::UnixStream; @@ -77,10 +77,13 @@ const INITIAL_SURROGATES_ENV_VAR: &str = "HYPERLIGHT_INITIAL_SURROGATES"; /// surrogates entirely. const MAX_SURROGATES_ENV_VAR: &str = "HYPERLIGHT_MAX_SURROGATES"; -/// Default maximum number of surrogate processes (macOS imposes no hard -/// per-process limit like WHP's 512 handles, but each surrogate is a real -/// running process, so keep the default modest). -const DEFAULT_MAX_SURROGATE_PROCESSES: usize = 64; +/// Default maximum number of surrogate processes. Sized to the platform's +/// concurrent-VM limit: macOS caps HVF VMs system-wide (measured 127 on +/// macOS 26, arm64), so a larger pool only stockpiles idle processes, and +/// a smaller one can deadlock threads that hold one surrogate while +/// acquiring another (pool backpressure is the correct behavior near the +/// cap; exceeding it yields a clean `HV_NO_RESOURCES` error). +const DEFAULT_MAX_SURROGATE_PROCESSES: usize = 128; /// A pooled surrogate process: the child handle plus our end of the /// control socketpair. @@ -375,7 +378,7 @@ fn compute_surrogate_counts(raw_initial: Option, raw_max: Option) /// variables, applying validation and clamping. /// /// - `HYPERLIGHT_INITIAL_SURROGATES`: clamped to `0..=max`, default 0. -/// - `HYPERLIGHT_MAX_SURROGATES`: default 64; `0` disables surrogates. +/// - `HYPERLIGHT_MAX_SURROGATES`: default 128; `0` disables surrogates. fn surrogate_process_counts() -> (usize, usize) { let raw_initial = std::env::var(INITIAL_SURROGATES_ENV_VAR) .ok() diff --git a/src/hyperlight_host/tests/integration_test.rs b/src/hyperlight_host/tests/integration_test.rs index b449ea68d..3f30b9a0a 100644 --- a/src/hyperlight_host/tests/integration_test.rs +++ b/src/hyperlight_host/tests/integration_test.rs @@ -1472,7 +1472,13 @@ fn interrupt_infinite_moving_loop_stress_test() { use std::thread; // We have a high thread count to stress test and to have interesting interleavings + #[cfg(not(target_os = "macos"))] const NUM_THREADS: usize = 200; + // macOS (HVF) caps concurrent VMs system-wide (measured 127 on macOS + // 26, arm64) and each thread holds two sandboxes; 48 threads (96 VMs) + // keeps the same interleavings comfortably below the platform cap. + #[cfg(target_os = "macos")] + const NUM_THREADS: usize = 48; let mut handles = vec![]; @@ -1833,7 +1839,7 @@ fn memory_region_types_are_publicly_accessible() { // MemoryRegion_ struct and all its fields are pub (struct literal // construction requires every field to be pub). - #[cfg(not(target_os = "windows"))] + #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] { let base: ::HostBaseType = 0x1000; let _region = MemoryRegion_:: { @@ -1844,6 +1850,25 @@ fn memory_region_types_are_publicly_accessible() { }; } + #[cfg(target_os = "macos")] + { + use hyperlight_host::mem::memory_region::HostRegionBase; + + let host_base = HostRegionBase { + name: String::new(), + offset: 0, + base: 0x1000, + path: None, + }; + let _region = MemoryRegion_:: { + guest_region: 0x1000..0x2000, + host_region: host_base.clone() + ..::add(host_base, 0x1000), + flags: MemoryRegionFlags::READ, + region_type: MemoryRegionType::Code, + }; + } + #[cfg(target_os = "windows")] { use hyperlight_host::hypervisor::wrappers::HandleWrapper; diff --git a/src/hyperlight_host/tests/snapshot_goldens/platform.rs b/src/hyperlight_host/tests/snapshot_goldens/platform.rs index 54be335c9..9b9d864ea 100644 --- a/src/hyperlight_host/tests/snapshot_goldens/platform.rs +++ b/src/hyperlight_host/tests/snapshot_goldens/platform.rs @@ -26,12 +26,17 @@ use crate::goldens_version::GOLDENS_VERSION; #[derive(Copy, Clone, Debug, PartialEq, Eq)] enum Hypervisor { - #[cfg_attr(target_os = "windows", allow(dead_code))] + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] Kvm, - #[cfg_attr(target_os = "windows", allow(dead_code))] + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] Mshv, #[cfg_attr(not(target_os = "windows"), allow(dead_code))] Whp, + #[cfg_attr( + not(all(target_os = "macos", target_arch = "aarch64", feature = "hvf")), + allow(dead_code) + )] + Hvf, } impl Hypervisor { @@ -40,12 +45,14 @@ impl Hypervisor { Self::Kvm => "kvm", Self::Mshv => "mshv", Self::Whp => "whp", + Self::Hvf => "hvf", } } /// Detect the locally available hypervisor. Order matches the /// host crate's preference: `/dev/mshv` over `/dev/kvm` on - /// Linux, WHP on Windows. + /// Linux, WHP on Windows, HVF on aarch64 macOS (when the `hvf` + /// feature is compiled in). fn detect() -> Option { #[cfg(target_os = "linux")] { @@ -61,7 +68,15 @@ impl Hypervisor { { Some(Self::Whp) } - #[cfg(not(any(target_os = "linux", target_os = "windows")))] + #[cfg(all(target_os = "macos", target_arch = "aarch64", feature = "hvf"))] + { + Some(Self::Hvf) + } + #[cfg(not(any( + target_os = "linux", + target_os = "windows", + all(target_os = "macos", target_arch = "aarch64", feature = "hvf") + )))] { None } diff --git a/src/hyperlight_libc/build.rs b/src/hyperlight_libc/build.rs index 2b2cfb382..cad3df7e1 100644 --- a/src/hyperlight_libc/build.rs +++ b/src/hyperlight_libc/build.rs @@ -285,6 +285,33 @@ fn cargo_main() -> Result<()> { unsafe { env::set_var("AR_x86_64_unknown_none", "llvm-ar") }; } + // On macOS hosts, Xcode's `ar`/`ranlib` silently drop the ELF objects + // produced for the guest target ("archive member ... not a mach-o + // file"), yielding an empty libhyperlight_libc.a. Use LLVM's tools + // instead (they understand both formats). + if cfg!(target_os = "macos") { + let llvm_tool = |tool: &str| { + [ + format!("/opt/homebrew/opt/llvm/bin/{tool}"), + format!("/usr/local/opt/llvm/bin/{tool}"), + tool.to_string(), + ] + .into_iter() + .find(|candidate| { + Command::new(candidate) + .arg("--version") + .output() + .is_ok_and(|o| o.status.success()) + }) + }; + if let Some(ar) = llvm_tool("llvm-ar") { + build.archiver(ar); + } + if let Some(ranlib) = llvm_tool("llvm-ranlib") { + build.ranlib(ranlib); + } + } + build.compile("hyperlight_libc"); copy_includes(&include_dir, picolibc_dir.join("libc/include"))?; copy_includes(&include_dir, picolibc_dir.join("libc/stdio"))?; From 92e4725edb943722c70c37a7ef0c20360c6896f1 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Wed, 22 Jul 2026 18:24:35 +0100 Subject: [PATCH 09/14] test(host): make map_region tests host-page-size aware The mapped-region tests constructed MemoryRegions with 4 KiB alignment assumptions, but HyperlightVm::map_region validates against the host page size (16 KiB on Apple silicon). Derive test region alignment from page_size::get() so the tests assert the intended Overlapping errors on any host page size; on 4K hosts they are byte-identical to before. Full lib suite is now 279 passed / 0 failed / 8 ignored on macOS. Signed-off-by: Sienna Meridian Satterwhite --- .../src/sandbox/initialized_multi_use.rs | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs index 8e60c68e7..0ba869aad 100644 --- a/src/hyperlight_host/src/sandbox/initialized_multi_use.rs +++ b/src/hyperlight_host/src/sandbox/initialized_multi_use.rs @@ -1532,9 +1532,10 @@ mod tests { } fn page_aligned_memory(src: &[u8]) -> GuestSharedMemory { - use hyperlight_common::mem::PAGE_SIZE_USIZE; - - let len = src.len().div_ceil(PAGE_SIZE_USIZE) * PAGE_SIZE_USIZE; + // Round up to the *host* page size: `map_region` requires + // host-page-aligned regions (4K on x86 hosts, 16K on arm64 + // macOS), so tests must not assume 4K pages. + let len = src.len().div_ceil(page_size::get()) * page_size::get(); let mut mem = ExclusiveSharedMemory::new(len).unwrap(); mem.copy_from_slice(src, 0).unwrap(); @@ -2642,16 +2643,18 @@ mod tests { u_sbox.evolve().unwrap() }; - // Use multi-page regions so partial overlap is geometrically possible - let mem1 = page_aligned_memory(&[0xAA; 8192]); // 2 pages - let mem2 = page_aligned_memory(&[0xBB; 8192]); // 2 pages + // Use multi-page regions so partial overlap is geometrically + // possible (2 pages each, in host page units) + let ps = page_size::get(); + let mem1 = page_aligned_memory(&vec![0xAA; 2 * ps]); + let mem2 = page_aligned_memory(&vec![0xBB; 2 * ps]); let guest_base: usize = 0x200000000; let region1 = region_for_memory(&mem1, guest_base, MemoryRegionFlags::READ); unsafe { sbox.map_region(®ion1).unwrap() }; - // region2 starts one page before region1, overlapping by one page - let overlap_base = guest_base - 0x1000; + // region2 starts one host page before region1, overlapping by one page + let overlap_base = guest_base - ps; let region2 = region_for_memory(&mem2, overlap_base, MemoryRegionFlags::READ); let err = unsafe { sbox.map_region(®ion2) }.unwrap_err(); assert!( @@ -2690,13 +2693,14 @@ mod tests { u_sbox.evolve().unwrap() }; - // Try to map at BASE_ADDRESS (0x1000) which overlaps the snapshot region + // Try to map inside the snapshot region (which starts at + // BASE_ADDRESS = 0x1000). BASE_ADDRESS itself is not necessarily + // host-page-aligned (16K hosts), so map at the next host-page + // boundary — still inside the snapshot region. + let guest_base = crate::mem::layout::SandboxMemoryLayout::BASE_ADDRESS + .next_multiple_of(page_size::get()); let mem = allocate_guest_memory(); - let region = region_for_memory( - &mem, - crate::mem::layout::SandboxMemoryLayout::BASE_ADDRESS, - MemoryRegionFlags::READ, - ); + let region = region_for_memory(&mem, guest_base, MemoryRegionFlags::READ); let err = unsafe { sbox.map_region(®ion) }.unwrap_err(); assert!( format!("{err:?}").contains("Overlapping"), @@ -2712,12 +2716,16 @@ mod tests { u_sbox.evolve().unwrap() }; - // The scratch region occupies the top of the GPA space + // The scratch region occupies the top of the GPA space. Its base + // is not necessarily host-page-aligned (16K hosts), so align down + // to the host page boundary; the region still overlaps the scratch + // region's first page. let scratch_addr = hyperlight_common::layout::scratch_base_gpa( crate::sandbox::SandboxConfiguration::DEFAULT_SCRATCH_SIZE, ) as usize; + let guest_base = scratch_addr / page_size::get() * page_size::get(); let mem = allocate_guest_memory(); - let region = region_for_memory(&mem, scratch_addr, MemoryRegionFlags::READ); + let region = region_for_memory(&mem, guest_base, MemoryRegionFlags::READ); let err = unsafe { sbox.map_region(®ion) }.unwrap_err(); assert!( format!("{err:?}").contains("verlap"), From 6cafc0eee67cc6373cf0c1196c488f7c94995a05 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Wed, 22 Jul 2026 18:24:36 +0100 Subject: [PATCH 10/14] ci: add macOS HVF build-test job - New build-test-macos-hvf job on GitHub-hosted macos-26 arm64 runners: installs cargo-hyperlight from a pinned git rev (crates.io 0.1.13 does not compile on macOS), builds rust and C guests, and runs the test-hvf recipe. Requires macOS 26 for the 4 KiB IPA granule API. Snapshot-golden verification is deferred until the hvf goldens are published to the registry. - Fix the test-hvf recipe to execute test binaries directly after codesigning instead of re-invoking cargo test: codesigning touches the binaries, which cargo treats as a rebuild trigger, silently discarding the entitlement and failing every sandbox test with HV_DENIED. Signed-off-by: Sienna Meridian Satterwhite --- .github/workflows/ValidatePullRequest.yml | 56 +++++++++++++++++++++++ Justfile | 15 ++++-- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ValidatePullRequest.yml b/.github/workflows/ValidatePullRequest.yml index 490647f09..1a64fef5a 100644 --- a/.github/workflows/ValidatePullRequest.yml +++ b/.github/workflows/ValidatePullRequest.yml @@ -179,6 +179,61 @@ jobs: config: ${{ matrix.config }} should_regen_goldens: ${{ needs.check-golden-label.outputs.should_regen_goldens }} + # Build and test the macOS HVF (Hypervisor.framework) backend on Apple silicon. + # Self-contained: guests are built and tested on the same GitHub-hosted arm64 + # runner (no artifact sharing with the other build-test jobs). Requires + # macOS 26 for the 4 KiB IPA granule API. + build-test-macos-hvf: + needs: [docs-pr, update-guest-locks] + # Required because update-guest-locks is skipped on non-dependabot PRs, + # and a skipped dependency transitively skips all downstream jobs. + # See: https://github.com/actions/runner/issues/2205 + if: ${{ !cancelled() && !failure() }} + timeout-minutes: 45 + runs-on: macos-26 + env: + # cargo-hyperlight builds a custom target/sysroot; run everything through + # the rustup proxy with the pinned toolchain (newer rustc rejects custom + # target specs, Homebrew rustc shadows the proxy otherwise). + RUSTUP_TOOLCHAIN: "1.94" + RUSTC: ~/.cargo/bin/rustc + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + submodules: true + + - uses: hyperlight-dev/ci-setup-workflow@f6bd9cc86d0737976d2128c8b8ced8edc017cbb4 # v1.9.0 + with: + rust-toolchain: "1.94" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # crates.io cargo-hyperlight 0.1.13 does not compile on macOS (the ar + # detection fix is only on main). Pin the git rev; switch back to the + # crates.io pin (just ensure-cargo-hyperlight) once a release > 0.1.13 + # ships. The Justfile's ensure-cargo-hyperlight accepts this binary + # because it reports version 0.1.13. + - name: Install cargo-hyperlight from git + run: cargo install --locked --git https://github.com/hyperlight-dev/cargo-hyperlight --rev 33384c0c4ed9dea4f0525943809fc444c41a27df cargo-hyperlight + + # llvm-ar/llvm-ranlib are needed to archive the picolibc objects for C + # guests; Xcode's ar silently drops ELF members. + - name: Install LLVM + run: brew install llvm + + - name: Build Rust and C guests + run: | + just build-and-move-rust-guests + just build-c-guests debug + just move-c-guests debug + + # Builds, ad-hoc codesigns (com.apple.security.hypervisor) and runs the + # hyperlight-host lib + integration test binaries with the hvf feature. + # Snapshot-golden verification is a no-op here until the hvf goldens are + # published to the golden registry (see RegenSnapshotGoldens.yml). + - name: Test with the HVF backend + run: just test-hvf debug + # Run examples - needs guest artifacts, runs in parallel with build-test run-examples: needs: @@ -255,6 +310,7 @@ jobs: - code-checks - check-golden-label - build-test + - build-test-macos-hvf - run-examples - fuzzing - spelling diff --git a/Justfile b/Justfile index fc865dadb..e4b76bc9e 100644 --- a/Justfile +++ b/Justfile @@ -250,15 +250,20 @@ test-isolated target=default-target features="" : # Ad-hoc codesigns hyperlight test binaries with the Hypervisor.framework # entitlement and runs the host test suite with the HVF backend (macOS/aarch64). # Guest binaries must already be built (see build-rust-guests). +# NOTE: the binaries are executed directly, not via `cargo test` — codesigning +# modifies them, which cargo would treat as a rebuild trigger, discarding the +# signature (and unsigned binaries get HV_DENIED from Hypervisor.framework). test-hvf target=default-target: #!/usr/bin/env bash set -euo pipefail profile={{ if target == "debug" { "dev" } else { target } }} - dir={{ if target == "debug" { "debug" } else { "release" } }} - {{ cargo-cmd }} test -p hyperlight-host --no-default-features -F hvf --profile=$profile --no-run - find {{ justfile_directory() }}/target/$dir/deps -maxdepth 1 -type f -perm +111 \ - -exec codesign --sign - --entitlements {{ justfile_directory() }}/dev/hvf-entitlements.plist --force {} \; - {{ cargo-cmd }} test -p hyperlight-host --no-default-features -F hvf --profile=$profile + out=$({{ cargo-cmd }} test -p hyperlight-host --no-default-features -F hvf --profile=$profile --no-run 2>&1) + bins=$(echo "$out" | sed -n 's/^ *Executable .*(\(.*\))$/\1/p') + for bin in $bins; do + codesign --sign - --entitlements {{ justfile_directory() }}/dev/hvf-entitlements.plist --force "$bin" + echo "=== $bin" + "$bin" + done # runs integration tests test-integration target=default-target features="": From c41ff81bad7e0a685a636ae5817721f76a5481b4 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Wed, 22 Jul 2026 20:08:45 +0100 Subject: [PATCH 11/14] fix(host): compile cleanly on aarch64 without a hypervisor backend aarch64 builds with no active backend (e.g. macOS without the hvf feature, or aarch64-linux with --no-default-features) failed to compile: HyperlightVm::new referenced the interrupt handle and VmError unconditionally. Return NoHypervisorFound early in that configuration and cfg-gate the now-dead register constants, VmExit variants and reset_vcpu defaults so -D warnings clippy configs stay clean. Also match arm64 (macOS uname -m) in the clippy feature script's aarch64 exclusions, and include hyperlight-hvf in MSRV verification. Signed-off-by: Sienna Meridian Satterwhite --- .github/workflows/dep_code_checks.yml | 2 +- hack/clippy-package-features.sh | 2 +- .../src/hypervisor/hyperlight_vm/aarch64.rs | 178 ++++++++++-------- .../hypervisor/regs/aarch64/special_regs.rs | 16 ++ .../src/hypervisor/virtual_machine/mod.rs | 7 +- src/hyperlight_host/src/mem/shared_mem.rs | 2 +- 6 files changed, 123 insertions(+), 84 deletions(-) diff --git a/.github/workflows/dep_code_checks.yml b/.github/workflows/dep_code_checks.yml index 6398adb2b..d654fdc10 100644 --- a/.github/workflows/dep_code_checks.yml +++ b/.github/workflows/dep_code_checks.yml @@ -83,7 +83,7 @@ jobs: run: just clippy-exhaustive release - name: Verify MSRV - run: ./dev/verify-msrv.sh hyperlight-host hyperlight-guest hyperlight-guest-bin hyperlight-common + run: ./dev/verify-msrv.sh hyperlight-host hyperlight-guest hyperlight-guest-bin hyperlight-common hyperlight-hvf - name: Check cargo features compile run: just check diff --git a/hack/clippy-package-features.sh b/hack/clippy-package-features.sh index 49b6716b8..35f055df4 100755 --- a/hack/clippy-package-features.sh +++ b/hack/clippy-package-features.sh @@ -36,7 +36,7 @@ fi EXCLUDED_FEATURES=("${REQUIRED_FEATURES[@]}") -if [[ -n "${TARGET_TRIPLE}" && "$TARGET_TRIPLE" =~ aarch64 ]] || [[ -z "${TARGET_TRIPLE}" && "$(uname -m)" = "aarch64" ]]; then +if [[ -n "${TARGET_TRIPLE}" && "$TARGET_TRIPLE" =~ aarch64 ]] || [[ -z "${TARGET_TRIPLE}" && "$(uname -m)" =~ ^(aarch64|arm64)$ ]]; then EXCLUDED_FEATURES+=("crashdump" "trace_guest" "mem_profile" "hw-interrupts" "gdb" "i686-guest" "nanvix-unstable") fi diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs index 67c33446b..562a55d55 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs @@ -26,6 +26,7 @@ use super::{ AccessPageTableError, CreateHyperlightVmError, DispatchGuestCallError, HyperlightVm, InitializeError, }; +#[cfg(any(kvm, mshv3, hvf))] use crate::hypervisor::InterruptHandleImpl; #[cfg(any(kvm, mshv3))] use crate::hypervisor::LinuxInterruptHandle; @@ -41,9 +42,9 @@ use crate::hypervisor::virtual_machine::hvf::{HvfSurrogateVm, HvfVm}; use crate::hypervisor::virtual_machine::kvm::KvmVm; #[cfg(any(kvm, mshv3, hvf))] use crate::hypervisor::virtual_machine::{HypervisorType, VmError}; -use crate::hypervisor::virtual_machine::{ - RegisterError, ResetVcpuError, VirtualMachine, get_available_hypervisor, -}; +use crate::hypervisor::virtual_machine::{RegisterError, ResetVcpuError}; +#[cfg(any(kvm, mshv3, hvf))] +use crate::hypervisor::virtual_machine::{VirtualMachine, get_available_hypervisor}; #[cfg(hvf)] use crate::hypervisor::{HvfInterruptTarget, MacOSInterruptHandle}; use crate::mem::mgr::{SandboxMemoryManager, SnapshotSharedMemory}; @@ -70,91 +71,110 @@ impl HyperlightVm { #[cfg(crashdump)] _rt_cfg: SandboxRuntimeConfig, #[cfg(feature = "mem_profile")] _trace_info: MemTraceInfo, ) -> std::result::Result { + // No hypervisor backend is compiled for this platform (e.g. aarch64 + // without the kvm, mshv3 or hvf features). + #[cfg(not(any(kvm, mshv3, hvf)))] + { + let _ = ( + snapshot_mem, + scratch_mem, + root_pt_addr, + next_action, + rsp_gva, + page_size, + config, + ); + Err(CreateHyperlightVmError::NoHypervisorFound) + } // TODO: support gdb on aarch64 - type VmType = Box; - #[cfg(any(kvm, mshv3))] - let (vm, interrupt_handle): (VmType, Arc) = { - let vm: VmType = match get_available_hypervisor() { - #[cfg(kvm)] - Some(HypervisorType::Kvm) => Box::new(KvmVm::new().map_err(VmError::CreateVm)?), - // TODO: mshv support - #[cfg(mshv3)] - Some(HypervisorType::Mshv) => { - return Err(CreateHyperlightVmError::NoHypervisorFound); - } - None => return Err(CreateHyperlightVmError::NoHypervisorFound), + #[cfg(any(kvm, mshv3, hvf))] + { + type VmType = Box; + #[cfg(any(kvm, mshv3))] + let (vm, interrupt_handle): (VmType, Arc) = { + let vm: VmType = match get_available_hypervisor() { + #[cfg(kvm)] + Some(HypervisorType::Kvm) => Box::new(KvmVm::new().map_err(VmError::CreateVm)?), + // TODO: mshv support + #[cfg(mshv3)] + Some(HypervisorType::Mshv) => { + return Err(CreateHyperlightVmError::NoHypervisorFound); + } + None => return Err(CreateHyperlightVmError::NoHypervisorFound), + }; + let interrupt_handle: Arc = + Arc::new(LinuxInterruptHandle { + state: AtomicU8::new(0), + tid: AtomicU64::new(unsafe { libc::pthread_self() as u64 }), + retry_delay: config.get_interrupt_retry_delay(), + sig_rt_min_offset: config.get_interrupt_vcpu_sigrtmin_offset(), + dropped: AtomicBool::new(false), + }); + (vm, interrupt_handle) }; - let interrupt_handle: Arc = Arc::new(LinuxInterruptHandle { - state: AtomicU8::new(0), - tid: AtomicU64::new(unsafe { libc::pthread_self() as u64 }), - retry_delay: config.get_interrupt_retry_delay(), - sig_rt_min_offset: config.get_interrupt_vcpu_sigrtmin_offset(), - dropped: AtomicBool::new(false), - }); - (vm, interrupt_handle) - }; - #[cfg(hvf)] - let (vm, interrupt_handle): (VmType, Arc) = { - // `config` only carries Linux signal settings today. - let _ = config; - match get_available_hypervisor() { - Some(HypervisorType::Hvf) => { - if hvf_surrogate_manager::surrogates_disabled() { - // Direct in-process backend: single VM per process. - let vm = HvfVm::new().map_err(VmError::CreateVm)?; - let interrupt_handle: Arc = - Arc::new(MacOSInterruptHandle { - state: AtomicU8::new(0), - target: HvfInterruptTarget::Local(vm.vcpu_id()), - dropped: AtomicBool::new(false), - }); - (Box::new(vm) as VmType, interrupt_handle) - } else { - // Surrogate backend: the VM lives in a pooled - // surrogate process; cancellation goes over the - // shared socket writer. - let vm = HvfSurrogateVm::new().map_err(VmError::CreateVm)?; - let interrupt_handle: Arc = - Arc::new(MacOSInterruptHandle { - state: AtomicU8::new(0), - target: HvfInterruptTarget::Remote(vm.cancel_writer()), - dropped: AtomicBool::new(false), - }); - (Box::new(vm) as VmType, interrupt_handle) + #[cfg(hvf)] + let (vm, interrupt_handle): (VmType, Arc) = { + // `config` only carries Linux signal settings today. + let _ = config; + match get_available_hypervisor() { + Some(HypervisorType::Hvf) => { + if hvf_surrogate_manager::surrogates_disabled() { + // Direct in-process backend: single VM per process. + let vm = HvfVm::new().map_err(VmError::CreateVm)?; + let interrupt_handle: Arc = + Arc::new(MacOSInterruptHandle { + state: AtomicU8::new(0), + target: HvfInterruptTarget::Local(vm.vcpu_id()), + dropped: AtomicBool::new(false), + }); + (Box::new(vm) as VmType, interrupt_handle) + } else { + // Surrogate backend: the VM lives in a pooled + // surrogate process; cancellation goes over the + // shared socket writer. + let vm = HvfSurrogateVm::new().map_err(VmError::CreateVm)?; + let interrupt_handle: Arc = + Arc::new(MacOSInterruptHandle { + state: AtomicU8::new(0), + target: HvfInterruptTarget::Remote(vm.cancel_writer()), + dropped: AtomicBool::new(false), + }); + (Box::new(vm) as VmType, interrupt_handle) + } } + None => return Err(CreateHyperlightVmError::NoHypervisorFound), } - None => return Err(CreateHyperlightVmError::NoHypervisorFound), - } - }; - vm.set_sregs(&CommonSpecialRegisters::defaults(root_pt_addr)) - .map_err(VmError::Register)?; + }; + vm.set_sregs(&CommonSpecialRegisters::defaults(root_pt_addr)) + .map_err(VmError::Register)?; - let snapshot_slot = 0u32; - let scratch_slot = 1u32; - let vm_can_reset_vcpu = vm.can_reset_vcpu(); - let mut ret = Self { - vm, - next_action, - rsp_gva, - interrupt_handle, - page_size, + let snapshot_slot = 0u32; + let scratch_slot = 1u32; + let vm_can_reset_vcpu = vm.can_reset_vcpu(); + let mut ret = Self { + vm, + next_action, + rsp_gva, + interrupt_handle, + page_size, - next_slot: scratch_slot + 1, - freed_slots: Vec::new(), + next_slot: scratch_slot + 1, + freed_slots: Vec::new(), - snapshot_slot, - snapshot_memory: None, - scratch_slot, - scratch_memory: None, + snapshot_slot, + snapshot_memory: None, + scratch_slot, + scratch_memory: None, - mmap_regions: Vec::new(), + mmap_regions: Vec::new(), - vm_can_reset_vcpu, - pending_tlb_flush: false, - }; - ret.update_snapshot_mapping(snapshot_mem)?; - ret.update_scratch_mapping(scratch_mem)?; - Ok(ret) + vm_can_reset_vcpu, + pending_tlb_flush: false, + }; + ret.update_snapshot_mapping(snapshot_mem)?; + ret.update_scratch_mapping(scratch_mem)?; + Ok(ret) + } } #[allow(clippy::too_many_arguments)] diff --git a/src/hyperlight_host/src/hypervisor/regs/aarch64/special_regs.rs b/src/hyperlight_host/src/hypervisor/regs/aarch64/special_regs.rs index 0d80977eb..7329aec24 100644 --- a/src/hyperlight_host/src/hypervisor/regs/aarch64/special_regs.rs +++ b/src/hyperlight_host/src/hypervisor/regs/aarch64/special_regs.rs @@ -32,26 +32,42 @@ pub(crate) struct CommonSpecialRegisters { pub(crate) sp_el1: u64, } +#[cfg(any(kvm, mshv3, hvf))] pub(crate) const TCR_EL1_PS_48: u64 = 0b101u64 << 32; +#[cfg(any(kvm, mshv3, hvf))] pub(crate) const TCR_EL1_TG0_4K: u64 = 0b00u64 << 14; +#[cfg(any(kvm, mshv3, hvf))] pub(crate) const TCR_EL1_TG1_4K: u64 = 0b10u64 << 30; +#[cfg(any(kvm, mshv3, hvf))] #[allow(clippy::identity_op)] pub(crate) const TCR_EL1_T0SZ_48: u64 = 16u64 << 0; +#[cfg(any(kvm, mshv3, hvf))] pub(crate) const TCR_EL1_T1SZ_48: u64 = 16u64 << 16; +#[cfg(any(kvm, mshv3, hvf))] pub(crate) const TCR_EL1_IRGN0_WB_AA: u64 = 0b01 << 8; +#[cfg(any(kvm, mshv3, hvf))] pub(crate) const TCR_EL1_ORGN0_WB_AA: u64 = 0b01 << 10; +#[cfg(any(kvm, mshv3, hvf))] pub(crate) const TCR_EL1_SH0_ISH: u64 = 0b11 << 12; +#[cfg(any(kvm, mshv3, hvf))] pub(crate) const MAIR_NORMAL_OWB_NT_AA: u64 = 0b1111_1111; +#[cfg(any(kvm, mshv3, hvf))] pub(crate) const MAIR_ITEM_WIDTH: u8 = 8; +#[cfg(any(kvm, mshv3, hvf))] pub(crate) const SCTLR_EL1_RES1: u64 = 0b11u64 << 28 | 0b11u64 << 22 | 0b1u64 << 20 | 0b1u64 << 11; +#[cfg(any(kvm, mshv3, hvf))] pub(crate) const SCTLR_EL1_M: u64 = 0b1u64 << 0; +#[cfg(any(kvm, mshv3, hvf))] pub(crate) const SCTLR_EL1_C: u64 = 0b1u64 << 2; +#[cfg(any(kvm, mshv3, hvf))] pub(crate) const SCTLR_EL1_I: u64 = 0b1u64 << 12; +#[cfg(any(kvm, mshv3, hvf))] pub(crate) const CPACR_EL1_FPEN_NO_TRAP: u64 = 0b11 << 20; +#[cfg(any(kvm, mshv3, hvf))] impl CommonSpecialRegisters { pub(crate) fn defaults(root_pt_addr: u64) -> Self { CommonSpecialRegisters { diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs index 24fa33229..dc4fa3c97 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs @@ -134,6 +134,7 @@ compile_error!( ); /// The various reasons a VM's vCPU can exit +#[cfg_attr(not(any(kvm, mshv3, target_os = "windows", hvf)), allow(dead_code))] pub(crate) enum VmExit { /// The vCPU has exited due to a debug event (usually breakpoint) #[cfg(gdb)] @@ -318,8 +319,8 @@ pub enum HypervisorError { #[error("Windows error: {0}")] WindowsError(#[from] windows_result::Error), #[cfg(hvf)] - #[error("HVF error: {0:#x}")] - HvfError(u32), + #[error("{0}")] + HvfError(#[from] hyperlight_hvf::core::HvfError), #[cfg(hvf)] #[error("HVF surrogate error: {0}")] HvfSurrogateError(String), @@ -391,10 +392,12 @@ pub(crate) trait VirtualMachine: Debug + Send { /// Single-operation vCPU reset #[cfg(target_arch = "aarch64")] + #[cfg_attr(not(any(kvm, mshv3, hvf)), allow(dead_code))] fn can_reset_vcpu(&self) -> bool { false } #[cfg(target_arch = "aarch64")] + #[cfg_attr(not(any(kvm, mshv3, hvf)), allow(dead_code))] fn reset_vcpu(&mut self) -> std::result::Result<(), ResetVcpuError> { Err(ResetVcpuError::NotSupported) } diff --git a/src/hyperlight_host/src/mem/shared_mem.rs b/src/hyperlight_host/src/mem/shared_mem.rs index 952fc990e..76e502839 100644 --- a/src/hyperlight_host/src/mem/shared_mem.rs +++ b/src/hyperlight_host/src/mem/shared_mem.rs @@ -893,7 +893,7 @@ impl ExclusiveSharedMemory { return Err(new_error!("Cannot create shared memory with size 0")); } - if min_size_bytes % PAGE_SIZE_USIZE != 0 { + if !min_size_bytes.is_multiple_of(PAGE_SIZE_USIZE) { return Err(new_error!( "shared memory must be a multiple of {}", PAGE_SIZE_USIZE From d997ee8c5e9c0939ff9989b87961c724cc5fd607 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Wed, 22 Jul 2026 20:09:01 +0100 Subject: [PATCH 12/14] feat(hvf): require 4 KiB IPA granule, box large IPC variants - VM creation now fails with a clear IpaGranuleUnsupported error when the 4 KiB IPA granule cannot be configured (pre-macOS 26), instead of silently falling back to a 16 KiB granule that cannot map 4 KiB-aligned guest regions. - Box the Regs/FpuState IPC payloads (clippy large_enum_variant) and collapse the host-side HypervisorError::HvfError variants into a single one carrying hyperlight_hvf::core::HvfError, which also fixes the enum_variant_names lint in the kvm,mshv3,hvf feature combo. Signed-off-by: Sienna Meridian Satterwhite --- .../src/hvf_surrogate/src/main.rs | 14 ++++++--- .../src/hypervisor/hvf_surrogate_manager.rs | 4 +-- .../src/hypervisor/virtual_machine/hvf/mod.rs | 30 +++++++++++++------ .../virtual_machine/hvf/surrogate.rs | 8 ++--- src/hyperlight_hvf/src/core.rs | 18 +++++++---- src/hyperlight_hvf/src/proto.rs | 11 +++---- 6 files changed, 56 insertions(+), 29 deletions(-) diff --git a/src/hyperlight_host/src/hvf_surrogate/src/main.rs b/src/hyperlight_host/src/hvf_surrogate/src/main.rs index 0fe41568d..05d4a14a2 100644 --- a/src/hyperlight_host/src/hvf_surrogate/src/main.rs +++ b/src/hyperlight_host/src/hvf_surrogate/src/main.rs @@ -300,13 +300,15 @@ fn handle_request( let Some(vm) = vm.as_ref() else { return err_no_vm(); }; - vm.regs().map(Response::Regs).unwrap_or_else(err) + vm.regs() + .map(|r| Response::Regs(Box::new(r))) + .unwrap_or_else(err) } Request::SetRegs(regs) => { let Some(vm) = vm.as_ref() else { return err_no_vm(); }; - vm.set_regs(®s) + vm.set_regs(regs.as_ref()) .map(|()| Response::Ok) .unwrap_or_else(err) } @@ -314,13 +316,17 @@ fn handle_request( let Some(vm) = vm.as_ref() else { return err_no_vm(); }; - vm.fpu().map(Response::Fpu).unwrap_or_else(err) + vm.fpu() + .map(|f| Response::Fpu(Box::new(f))) + .unwrap_or_else(err) } Request::SetFpu(fpu) => { let Some(vm) = vm.as_ref() else { return err_no_vm(); }; - vm.set_fpu(&fpu).map(|()| Response::Ok).unwrap_or_else(err) + vm.set_fpu(fpu.as_ref()) + .map(|()| Response::Ok) + .unwrap_or_else(err) } Request::GetSregs => { let Some(vm) = vm.as_ref() else { diff --git a/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs b/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs index 5a87e5c40..a253be5cc 100644 --- a/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs +++ b/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs @@ -574,7 +574,7 @@ mod tests { use hyperlight_hvf::proto::Backing; let mgr = get_hvf_surrogate_process_manager().unwrap(); - let mut proc = mgr.get_surrogate_process().unwrap(); + let proc = mgr.get_surrogate_process().unwrap(); let sock = connect(proc.socket()); handshake(&sock); assert!(matches!(request(&sock, &Request::CreateVm), Response::Ok)); @@ -645,7 +645,7 @@ mod tests { ..Default::default() }; assert!(matches!( - request(&sock, &Request::SetRegs(regs)), + request(&sock, &Request::SetRegs(Box::new(regs))), Response::Ok )); diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/mod.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/mod.rs index 059f52fa0..965c7a6d0 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/mod.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/mod.rs @@ -30,11 +30,11 @@ limitations under the License. //! this module adapts it to the [`VirtualMachine`] trait and hosts the //! register/exit conversions shared by both backends. -use hyperlight_hvf::core::{self, FpuState, HvfError, Regs, Sregs}; +use hyperlight_hvf::core::{self, FpuState, Regs, Sregs}; use tracing::{Span, instrument}; use crate::hypervisor::regs::{CommonFpu, CommonRegisters, CommonSpecialRegisters}; -use crate::hypervisor::virtual_machine::{HypervisorError, VmExit}; +use crate::hypervisor::virtual_machine::VmExit; pub(crate) mod direct; pub(crate) mod surrogate; @@ -51,17 +51,19 @@ pub(crate) fn is_hypervisor_present() -> bool { core::is_hypervisor_present() } -impl From for HypervisorError { - fn from(e: HvfError) -> Self { - match e { - HvfError::Hv(code) => HypervisorError::HvfError(code), - HvfError::NoInstructionSyndrome => HypervisorError::HvfError(0), +impl From for CommonRegisters { + fn from(r: Regs) -> Self { + CommonRegisters { + x: r.x, + sp: r.sp, + pc: r.pc, + pstate: r.pstate, } } } -impl From for CommonRegisters { - fn from(r: Regs) -> Self { +impl From<&Regs> for CommonRegisters { + fn from(r: &Regs) -> Self { CommonRegisters { x: r.x, sp: r.sp, @@ -92,6 +94,16 @@ impl From for CommonFpu { } } +impl From<&FpuState> for CommonFpu { + fn from(f: &FpuState) -> Self { + CommonFpu { + v: f.v, + fpsr: f.fpsr, + fpcr: f.fpcr, + } + } +} + impl From<&CommonFpu> for FpuState { fn from(f: &CommonFpu) -> Self { FpuState { diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/surrogate.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/surrogate.rs index 544962b98..59cc24b2a 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/surrogate.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/surrogate.rs @@ -221,7 +221,7 @@ impl VirtualMachine for HvfSurrogateVm { fn regs(&self) -> std::result::Result { match self.request(&Request::GetRegs) { - Ok(Response::Regs(regs)) => Ok(regs.into()), + Ok(Response::Regs(regs)) => Ok(regs.as_ref().into()), Ok(Response::Err(e)) => Err(RegisterError::GetRegs( HypervisorError::HvfSurrogateError(e), )), @@ -233,14 +233,14 @@ impl VirtualMachine for HvfSurrogateVm { } fn set_regs(&self, regs: &CommonRegisters) -> std::result::Result<(), RegisterError> { - self.request(&Request::SetRegs(regs.into())) + self.request(&Request::SetRegs(Box::new(regs.into()))) .and_then(expect_ok) .map_err(RegisterError::SetRegs) } fn fpu(&self) -> std::result::Result { match self.request(&Request::GetFpu) { - Ok(Response::Fpu(fpu)) => Ok(fpu.into()), + Ok(Response::Fpu(fpu)) => Ok(fpu.as_ref().into()), Ok(Response::Err(e)) => { Err(RegisterError::GetFpu(HypervisorError::HvfSurrogateError(e))) } @@ -252,7 +252,7 @@ impl VirtualMachine for HvfSurrogateVm { } fn set_fpu(&self, fpu: &CommonFpu) -> std::result::Result<(), RegisterError> { - self.request(&Request::SetFpu(fpu.into())) + self.request(&Request::SetFpu(Box::new(fpu.into()))) .and_then(expect_ok) .map_err(RegisterError::SetFpu) } diff --git a/src/hyperlight_hvf/src/core.rs b/src/hyperlight_hvf/src/core.rs index 1c27dba7e..50c591906 100644 --- a/src/hyperlight_hvf/src/core.rs +++ b/src/hyperlight_hvf/src/core.rs @@ -55,6 +55,11 @@ pub enum HvfError { /// syndrome, so the access details (register, size) cannot be decoded. #[error("HVF data abort without valid instruction syndrome")] NoInstructionSyndrome, + /// The 4 KiB IPA granule could not be configured. Hyperlight requires + /// it (the default 16 KiB granule is incompatible with 4 KiB-aligned + /// guest regions); the granule API is available since macOS 26. + #[error("HVF 4 KiB IPA granule is not supported (requires macOS 26 or later)")] + IpaGranuleUnsupported, } /// Convert an `hv_return_t` into a `Result`. @@ -226,8 +231,9 @@ pub enum VmExit { /// Create the process-wide VM, requesting the 4 KiB IPA granule so that /// 4 KiB-aligned guest regions can be mapped (the granule API requires -/// macOS 26; the default granule is 16 KiB). Falls back to the default -/// configuration if the config path fails for any reason. +/// macOS 26; the default granule is 16 KiB, which Hyperlight's 4 KiB +/// guest page granule is incompatible with). Falls back to the default +/// configuration if the config object itself cannot be created. fn create_vm() -> Result<(), HvfError> { // SAFETY: `hv_vm_config_create` returns a retained config object (or // null), which is released below. @@ -244,9 +250,11 @@ fn create_vm() -> Result<(), HvfError> { }; // SAFETY: `config` is no longer needed. unsafe { os_release(config) }; - if ret == hv_error_t::HV_SUCCESS as i32 { - return Ok(()); - } + return check_hv(ret).map_err(|e| match e { + // Most likely a pre-macOS 26 system without the granule API. + HvfError::Hv(_) => HvfError::IpaGranuleUnsupported, + e => e, + }); } // SAFETY: a null config creates a VM with the default configuration. check_hv(unsafe { hv_vm_create(std::ptr::null_mut()) }) diff --git a/src/hyperlight_hvf/src/proto.rs b/src/hyperlight_hvf/src/proto.rs index 266dd15a9..75ba7378b 100644 --- a/src/hyperlight_hvf/src/proto.rs +++ b/src/hyperlight_hvf/src/proto.rs @@ -32,7 +32,8 @@ limitations under the License. use std::io::{self, Read, Write}; use std::path::PathBuf; -use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; use crate::core::{FpuState, Perms, Regs, Sregs, VmExit}; @@ -106,11 +107,11 @@ pub enum Request { /// Read the general-purpose registers. Responds with [`Response::Regs`]. GetRegs, /// Write the general-purpose registers. Responds with [`Response::Ok`]. - SetRegs(Regs), + SetRegs(Box), /// Read the SIMD/FP registers. Responds with [`Response::Fpu`]. GetFpu, /// Write the SIMD/FP registers. Responds with [`Response::Ok`]. - SetFpu(FpuState), + SetFpu(Box), /// Read the system registers. Responds with [`Response::Sregs`]. GetSregs, /// Write the system registers. Responds with [`Response::Ok`]. @@ -136,9 +137,9 @@ pub enum Response { /// The request succeeded with no payload. Ok, /// Answer to [`Request::GetRegs`]. - Regs(Regs), + Regs(Box), /// Answer to [`Request::GetFpu`]. - Fpu(FpuState), + Fpu(Box), /// Answer to [`Request::GetSregs`]. Sregs(Sregs), /// Answer to [`Request::RunVcpu`]. From 8019c2507a998ee9746ac8ef24e13ac541f47b12 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Wed, 22 Jul 2026 20:09:01 +0100 Subject: [PATCH 13/14] docs: document macOS HVF support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getting-started.md: macOS platform section — hvf cargo feature, macOS 26 requirement, the com.apple.security.hypervisor entitlement and ad-hoc codesigning, one-VM-per-process and the surrogate model, guest toolchain notes (rustup, LLVM, cargo-hyperlight from git). - hyperlight-surrogate-development-notes.md: contrast the HVF surrogate (a real server process with an IPC protocol) with the WHP surrogate (an empty suspended address-space container). - README.md: list Hypervisor.framework as a supported hypervisor. Signed-off-by: Sienna Meridian Satterwhite --- README.md | 2 +- docs/getting-started.md | 60 +++++++++++++++++++ .../hyperlight-surrogate-development-notes.md | 11 ++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ade86b6d2..af6655797 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Hyperlight lets you safely run untrusted code inside hypervisor-isolated micro VMs that spin up in milliseconds, with guest function calls completing in microseconds. You embed it as a library in your Rust application, hand it a guest binary, and call functions across the VM boundary as naturally as calling a local function. To minimize startup time and memory footprint, there's no guest kernel or OS. Guests are purpose-built using the Hyperlight guest library. -- Supports [KVM](https://linux-kvm.org/page/Main_Page), [MSHV](https://github.com/rust-vmm/mshv), and [Windows Hypervisor Platform](https://docs.microsoft.com/en-us/virtualization/api/#windows-hypervisor-platform) +- Supports [KVM](https://linux-kvm.org/page/Main_Page), [MSHV](https://github.com/rust-vmm/mshv), [Windows Hypervisor Platform](https://docs.microsoft.com/en-us/virtualization/api/#windows-hypervisor-platform), and [Hypervisor.framework](https://developer.apple.com/documentation/hypervisor) (macOS on Apple silicon) - No kernel or OS in the VM. Guests are regular ELF binaries written in `no_std` Rust or C - Host and guest communicate through typed function calls - Guests are sandboxed by default with no access to the host filesystem, network, etc. diff --git a/docs/getting-started.md b/docs/getting-started.md index 09c9e242a..e5ae00023 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -9,6 +9,7 @@ These are the minimum requirements to use Hyperlight as a library in your own pr - **A supported hypervisor:** - Linux: [KVM](https://help.ubuntu.com/community/KVM/Installation) or Microsoft Hypervisor (MSHV) - Windows: [Windows Hypervisor Platform](https://docs.microsoft.com/en-us/virtualization/api/#windows-hypervisor-platform) (WHP) + - macOS (Apple silicon): [Hypervisor.framework](https://developer.apple.com/documentation/hypervisor) (HVF) - **[Rust](https://www.rust-lang.org/tools/install)**, installed via `rustup`. - **Platform build tools** (provides the C linker required by Rust): - Ubuntu/Debian: `sudo apt install build-essential` @@ -93,6 +94,65 @@ Requires Windows 11 Pro/Enterprise/Education or Windows Server 2025 or later. sudo dnf install clang ``` +### macOS + +Requires macOS 26 or later on Apple silicon. Hyperlight uses +[Hypervisor.framework](https://developer.apple.com/documentation/hypervisor) (HVF) +via the `hvf` cargo feature of `hyperlight-host`, which is not enabled by default: + +```toml +hyperlight-host = { version = "0.16.0", features = ["hvf"] } +``` + +Two macOS-specific things to know: + +1. **Entitlement.** Every executable that creates a VM — your application, and + Hyperlight's per-sandbox surrogate processes — must be signed with the + `com.apple.security.hypervisor` entitlement. Ad-hoc signing is sufficient for + development. Save this as `hvf-entitlements.plist`: + + ```xml + + + + + com.apple.security.hypervisor + + + + ``` + + and sign your binary after every build: + + ```sh + codesign --sign - --entitlements hvf-entitlements.plist --force target/debug/your-app + ``` + + Hyperlight's surrogate binaries are extracted and ad-hoc signed automatically + at runtime; only your own executables need manual signing. Without the + entitlement, sandbox creation fails with `NoHypervisorFound`. + +2. **One VM per process.** Hypervisor.framework allows only a single VM per + process, so Hyperlight runs each sandbox's VM in a small surrogate helper + process (see [Hyperlight Surrogate](./hyperlight-surrogate-development-notes.md)). + This is transparent in normal use. Setting `HYPERLIGHT_MAX_SURROGATES=0` + disables surrogates and selects the in-process backend, which limits the + process to one live sandbox at a time. + +If building guest binaries on macOS: + +- Use `rustup` for your Rust installation — guest builds require the toolchain + pinned in `rust-toolchain.toml`, and distributions that shadow it (e.g. + Homebrew Rust) do not work. +- Install LLVM (`brew install llvm`); Xcode's `ar` cannot archive the ELF + objects used when linking C guests. +- Until a `cargo-hyperlight` release newer than 0.1.13 is published, install it + from git (0.1.13 does not compile on macOS): + + ```sh + cargo install --locked --git https://github.com/hyperlight-dev/cargo-hyperlight cargo-hyperlight + ``` + ### WSL2 Follow the Ubuntu/Debian instructions above inside your WSL2 instance. WSL2 uses KVM. diff --git a/docs/hyperlight-surrogate-development-notes.md b/docs/hyperlight-surrogate-development-notes.md index 431cc85da..23c263337 100644 --- a/docs/hyperlight-surrogate-development-notes.md +++ b/docs/hyperlight-surrogate-development-notes.md @@ -9,3 +9,14 @@ These surrogate processes are managed by the host via the [surrogate_process_man > **Note:** `HYPERLIGHT_MAX_SURROGATES` is authoritative — if `HYPERLIGHT_INITIAL_SURROGATES` exceeds it, the initial count is silently clamped down to the maximum. For example, setting only `HYPERLIGHT_MAX_SURROGATES=256` limits both the initial pool and the ceiling to 256. `hyperlight_surrogate.exe` gets built during `hyperlight-host`'s build script, gets embedded into the `hyperlight-host` Rust library via [rust-embed](https://crates.io/crates/rust-embed), and is extracted at runtime next to the executable when the surrogate process manager is initialized. The extracted filename includes a short BLAKE3 hash of the binary content (e.g., `hyperlight_surrogate_a1b2c3d4.exe`) so that multiple hyperlight versions can coexist without file-deletion races. + +### HVF surrogate (macOS) + +On macOS with Hypervisor.framework (HVF) the constraint is similar — only one VM per process — but the implementation is fundamentally different. HVF binds a VM to the process that created it and offers no cross-process mapping API like `WHvMapGpaRange2`, so `hvf_surrogate` is a real server process rather than an empty suspended one: + +- Each sandbox's VM and vCPU are created, run, and destroyed inside a pooled `hvf_surrogate` process; the host is an IPC client (length-prefixed JSON frames over an inherited unix socket pair; the protocol is defined in the `hyperlight-hvf` crate's `proto` module). +- Guest memory is never copied: the host allocates it from named POSIX shm objects (or file-backed regions), and the surrogate maps the same objects into its own address space and then into the guest. +- The surrogate's main thread is the vCPU thread — HVF requires vCPU creation, register access, and `hv_vcpu_run` to happen on a single thread. A reader thread dispatches requests and answers `Cancel` immediately via `hv_vcpus_exit` (the only HVF call allowed from a non-owning thread); a writer thread serializes all responses. +- The extracted surrogate binary is ad-hoc codesigned with the `com.apple.security.hypervisor` entitlement at extraction time, since Hypervisor.framework refuses to create VMs from unentitled binaries. + +The pool is managed by `hvf_surrogate_manager` and honors the same `HYPERLIGHT_INITIAL_SURROGATES` / `HYPERLIGHT_MAX_SURROGATES` environment variables (defaulting to 0 initial / 128 max — HVF currently allows 127 concurrent VMs system-wide, so exceeding the pool fails fast rather than surprising the guest). Setting `HYPERLIGHT_MAX_SURROGATES=0` disables surrogates and selects the direct in-process backend, which limits the process to a single live sandbox at a time. From 473bfe2ecc8fbc89af75c86319418de3b570d6e6 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Wed, 22 Jul 2026 20:14:09 +0100 Subject: [PATCH 14/14] fix(host): allow expect on pooled surrogate socket in release clippy Follows the crate's existing pattern for justified invariants (clippy::expect_used only fires in release): the socket is only accessible while the process is checked out of the pool. Signed-off-by: Sienna Meridian Satterwhite --- src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs b/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs index a253be5cc..cf46144b3 100644 --- a/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs +++ b/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs @@ -107,6 +107,7 @@ pub(crate) struct HvfSurrogateProcess { impl HvfSurrogateProcess { /// The control socket connected to the surrogate server (clone it with /// `try_clone` to share it across threads). + #[allow(clippy::expect_used)] // `inner` is only `None` after Drop has taken it pub(crate) fn socket(&self) -> &UnixStream { &self .inner