From cdff3323a6c7e2d9512d01941e34f03e763a9646 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sat, 7 Nov 2026 00:08:55 -0800 Subject: [PATCH 1/2] host: spawn prepared VMs with verified ownership Consume the prepared launch without reopening its resources and return its verified detached process, outputs and capability. Confirm the owned child exits if process verification fails. Preserve Linux umask handling and require a trusted Windows log-named job, with internal inheritance locking. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b4a2ff9d-56d5-4635-9dc8-a5f78c8cbc6a Copilot-Session: d5e2370f-2500-4250-a1da-70d1661912e1 --- nvx-rs/README.md | 27 +- nvx-rs/public-api/linux.txt | 5 + nvx-rs/public-api/windows.txt | 5 + nvx-rs/src/host/platform/tests.rs | 4 +- nvx-rs/src/host/platform/windows/spawn.rs | 21 ++ nvx-rs/src/host/platform/windows/tests.rs | 24 +- nvx-rs/src/host/spawn.rs | 47 +++ nvx-rs/src/host/vm.rs | 92 +++++ nvx-rs/src/host/vm/tests.rs | 436 ++++++++++++++++++++++ 9 files changed, 658 insertions(+), 3 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index f4fae8f37..c8850c7ba 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -124,7 +124,7 @@ changes only when the module's purpose does. | [`nvx::Hypervisor`](#nvxhypervisor) | KVM, MSHV, or WHP, with its serde form behind the `serde` feature | | [`nvx::exec`](#nvxexec) | The execution engine: the streams, credit, and outcome of an execution's two sides, without I/O, for any framing | | [`nvx::host::runtime`](#nvxhostruntime) | `Runtime`: what every launch shares, the runtime's files and their identity, the hypervisor and its probe, and the launch options | -| [`nvx::host::vm`](#nvxhostvm) | A private VM directory and its fixed outputs, and an inspectable cold launch with its opened log and control credential | +| [`nvx::host::vm`](#nvxhostvm) | A private VM directory, an inspectable cold preparation, and an owned, verified detached launch | | [`nvx::host::snapshot`](#nvxhostsnapshot) | Snapshot compatibility keys, strict manifests, restore-time eligibility, and the opaque input of a future VM restore | | [`nvx::host::config`](#nvxhostconfig) | `VmConfig` and `CaptureConfig`: pure configuration, cold-boot kernel/OpenVMM arguments, portable length checks, and capture identity | | [`nvx::host::restore`](#nvxhostrestore) | A caller-driven restore to Ready without I/O or a clock, and its peer-verified restore-ready listener | @@ -512,6 +512,31 @@ directory and runtime inputs stable through creation and a later spawn. Dropping a successful preparation closes its resources but leaves its output paths owned by the caller. +`vm::spawn(prepared)` consumes that preparation and returns `Launched` with +its verified `process`, the same `outputs`, and the same `capability`. The log +and inherited resources are passed as already opened objects, never reopened +by name. The capability reaches OpenVMM only on standard input, then +end-of-file, not in arguments, environment variables, or a file. A failure +after child creation terminates that child and confirms its exit; a cleanup +failure is reported with the original error. Spawn removes no output path. +Dropping `Launched` closes the process handle but does not stop the VM. + +On Linux the detached child gets the caller's umask with the owner's bits +cleared; the caller's own umask is unchanged. On Windows it starts without a +console in its log-named job, refusing a taken or untrusted job name, and +leaves the caller's job when the runtime requests breakaway. The inheritance +lock is internal to spawn. Windows callers coordinate their own other spawns, +for example with their own lock around VM and other process starts: an +unrelated process started during the inheritance window may inherit handles, +but never the VM's job claim. + +The [VM tests](src/host/vm/tests.rs) cover cleanup after child creation, +ownership, caller survival, the Linux umask, and Windows job claims. Their +ignored cold-spawn smoke test runs the current library against explicit +`NVX_VM_SPAWN_OPENVMM`, `NVX_VM_SPAWN_KERNEL`, and `NVX_VM_SPAWN_INITRAMFS` +paths, using a fresh `NVX_VM_SPAWN_PROOF_DIR`. It checks a guest boot marker, +responsive shell input, and a verified clean exit; it downloads no artifact. + ### `nvx::host::snapshot` `SnapshotKey` and `SnapshotManifest` own the compatibility key and strict diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 164361fdd..fd53490f6 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -3364,6 +3364,10 @@ fn nvx::host::template::status_evidence mod nvx::host::vm struct nvx::host::vm::Capability method nvx::host::vm::Capability::as_bytes +struct nvx::host::vm::Launched +field nvx::host::vm::Launched::capability +field nvx::host::vm::Launched::outputs +field nvx::host::vm::Launched::process struct nvx::host::vm::Prepared method nvx::host::vm::Prepared::arguments method nvx::host::vm::Prepared::capability @@ -3380,6 +3384,7 @@ field nvx::host::vm::VmOutputs::log field nvx::host::vm::VmOutputs::outcome fn nvx::host::vm::create fn nvx::host::vm::create_private_dir +fn nvx::host::vm::spawn mod nvx::net struct nvx::net::Cidr method nvx::net::Cidr::contains diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index 558c5d37e..bba6c1433 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -2275,6 +2275,10 @@ fn nvx::host::template::status_evidence mod nvx::host::vm struct nvx::host::vm::Capability method nvx::host::vm::Capability::as_bytes +struct nvx::host::vm::Launched +field nvx::host::vm::Launched::capability +field nvx::host::vm::Launched::outputs +field nvx::host::vm::Launched::process struct nvx::host::vm::Prepared method nvx::host::vm::Prepared::arguments method nvx::host::vm::Prepared::capability @@ -2291,6 +2295,7 @@ field nvx::host::vm::VmOutputs::log field nvx::host::vm::VmOutputs::outcome fn nvx::host::vm::create fn nvx::host::vm::create_private_dir +fn nvx::host::vm::spawn mod nvx::net struct nvx::net::Cidr method nvx::net::Cidr::contains diff --git a/nvx-rs/src/host/platform/tests.rs b/nvx-rs/src/host/platform/tests.rs index 78edf9022..637b3053e 100644 --- a/nvx-rs/src/host/platform/tests.rs +++ b/nvx-rs/src/host/platform/tests.rs @@ -5,7 +5,9 @@ pub(crate) use super::linux::tests::{ #[cfg(windows)] pub(crate) use super::windows::tests::set_access; #[cfg(windows)] -pub(crate) use super::windows::tests::{access, user_sid}; +pub(crate) use super::windows::tests::{ + access, inherits_file, untrusted_log_job, user_sid, with_spawn_window, +}; use super::*; #[cfg(any(target_os = "linux", windows))] diff --git a/nvx-rs/src/host/platform/windows/spawn.rs b/nvx-rs/src/host/platform/windows/spawn.rs index 25c40aa3a..e89348406 100644 --- a/nvx-rs/src/host/platform/windows/spawn.rs +++ b/nvx-rs/src/host/platform/windows/spawn.rs @@ -76,6 +76,27 @@ impl LaunchedProcess { unsafe { WaitForSingleObject(raw(&self.process), milliseconds) == WAIT_OBJECT_0 } } + /// Terminates this owned child and waits for its exit before releasing its handle. + pub(crate) fn terminate_and_wait(self) -> io::Result<()> { + // SAFETY: CreateProcessW returned the owned handle with full access. + let termination = if unsafe { TerminateProcess(raw(&self.process), 1) } == 0 { + Some(io::Error::last_os_error()) + } else { + None + }; + // SAFETY: the process handle stays open for the whole wait. + if unsafe { WaitForSingleObject(raw(&self.process), INFINITE) } == WAIT_OBJECT_0 { + return Ok(()); + } + let wait = io::Error::last_os_error(); + Err(match termination { + None => wait, + Some(termination) => io::Error::other(format!( + "cannot terminate the child: {termination}; cannot wait for its exit: {wait}" + )), + }) + } + /// Waits up to `timeout` for the process to exit, and returns its exit status if it has, /// as `Popen.wait` reads it with `GetExitCodeProcess`. #[cfg(feature = "cli")] diff --git a/nvx-rs/src/host/platform/windows/tests.rs b/nvx-rs/src/host/platform/windows/tests.rs index 658ef11b9..8a82a73c5 100644 --- a/nvx-rs/src/host/platform/windows/tests.rs +++ b/nvx-rs/src/host/platform/windows/tests.rs @@ -16,7 +16,7 @@ use windows_sys::Win32::Foundation::{ use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; use windows_sys::Win32::System::JobObjects::CreateJobObjectW; use windows_sys::Win32::System::Threading::{ - CreateEventW, GetCurrentProcess, INFINITE, TerminateProcess, + CreateEventW, GetCurrentProcess, INFINITE, OpenProcess, PROCESS_DUP_HANDLE, TerminateProcess, }; use super::identity::status_error; @@ -44,6 +44,28 @@ pub(super) fn in_window() { }); } +pub(crate) fn with_spawn_window( + hook: impl FnMut() + 'static, + operation: impl FnOnce() -> T, +) -> T { + IN_WINDOW.set(Some(Box::new(hook))); + let result = operation(); + IN_WINDOW.set(None); + result +} + +pub(crate) fn inherits_file(pid: u32, value: usize, file: &fs::File) -> bool { + // SAFETY: opens only the test's already verified child to inspect its handle table. + let child = owned(unsafe { OpenProcess(PROCESS_DUP_HANDLE, 0, pid) }) + .unwrap_or_else(|| panic!("cannot inspect the child: {}", io::Error::last_os_error())); + holds(&child, value as HANDLE, file) +} + +pub(crate) fn untrusted_log_job(log: &fs::File) -> OwnedHandle { + let id = log_job_id(log).unwrap().unwrap(); + job_with_descriptor(&log_job_name(id), "D:P(A;;GA;;;WD)") +} + fn line(program: &str, arguments: &[&str]) -> String { let arguments: Vec = arguments.iter().map(OsString::from).collect(); let mut units = command_line(OsStr::new(program), &arguments).unwrap(); diff --git a/nvx-rs/src/host/spawn.rs b/nvx-rs/src/host/spawn.rs index e6bd2f2d7..fd71827ab 100644 --- a/nvx-rs/src/host/spawn.rs +++ b/nvx-rs/src/host/spawn.rs @@ -107,6 +107,27 @@ pub fn spawn( capability: &[u8; CAPABILITY_LEN], log: File, ) -> io::Result { + spawn_inner(command, capability, log, false) +} + +/// Starts a library VM only with a trusted log-named job on Windows. +pub(crate) fn spawn_vm( + command: &VmmCommand<'_>, + capability: &[u8; CAPABILITY_LEN], + log: File, +) -> io::Result { + spawn_inner(command, capability, log, true) +} + +/// Shares the legacy spawn mechanics without its optional Windows log-job fallback for a VM. +fn spawn_inner( + command: &VmmCommand<'_>, + capability: &[u8; CAPABILITY_LEN], + log: File, + require_log_job: bool, +) -> io::Result { + #[cfg(not(windows))] + let _ = require_log_job; let (stdin, mut writer) = io::pipe()?; writer.write_all(capability)?; drop(writer); @@ -114,6 +135,12 @@ pub fn spawn( #[cfg(windows)] { let job = platform::create_log_job(&log)?; + if require_log_job && job.is_none() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "the VM log has no trusted log-named job", + )); + } platform::spawn_detached( &command.program, &command.arguments, @@ -261,6 +288,26 @@ impl Launched { } } + /// Terminates this owned child and waits without a deadline for proof of its exit. + /// + /// Unlike legacy cleanup's timeout, this never detaches a child after a failed verification. + pub(crate) fn kill_and_confirm_exit(self) -> io::Result<()> { + #[cfg(windows)] + return self.process.terminate_and_wait(); + #[cfg(not(windows))] + { + let mut child = self.child; + let termination = child.kill(); + // A refused signal needs no repair when the wait proves the child already exited. + child.wait().map(|_| ()).map_err(|wait| match termination { + Ok(()) => wait, + Err(termination) => io::Error::other(format!( + "cannot terminate the child: {termination}; cannot wait for its exit: {wait}" + )), + }) + } + } + /// Waits up to `timeout` for the process to exit, and returns its status if it has, as /// `Popen.wait` and `Popen.poll` report it: the exit code, or on Unix the negated number of /// the signal that ended the process. diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index 9aced91f2..b0404013a 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -26,6 +26,8 @@ use crate::host::platform::{check_store_dir as check_dir, create_store_dir as cr use crate::host::platform::{check_vm_dir as check_dir, create_vm_dir as create_dir}; use crate::host::runtime::Runtime; use crate::host::snapshot::Snapshot; +use crate::host::spawn::{self, VmmCommand}; +use crate::host::{platform::inherit::Inherited, process::Process}; use crate::protocol::CAPABILITY_LEN; use crate::{Error, Result}; @@ -199,6 +201,12 @@ impl fmt::Debug for Capability { /// Dropping a successful preparation closes its files but leaves its output paths to the caller. #[derive(Debug)] pub struct Prepared { + /// The executable checked during creation. + program: PathBuf, + /// The private VM directory in which the child starts. + working_dir: PathBuf, + /// Whether the child leaves the caller's Windows job. + windows_breakaway: bool, /// The control credential, never written to disk. capability: Capability, /// The launch's fixed file paths and chosen endpoints. @@ -337,6 +345,9 @@ pub fn create( })(); match checked { Ok((capability, arguments)) => Ok(Prepared { + program: runtime.openvmm.clone(), + working_dir: dir.path().to_owned(), + windows_breakaway: runtime.windows_breakaway, capability, outputs, log, @@ -361,6 +372,87 @@ pub fn create( } } +/// A detached VM owned by its caller. +/// +/// Dropping this value closes the process handle, not the VM. Its outputs remain caller-owned, +/// and its capability is the same credential returned by the preparation. +#[derive(Debug)] +pub struct Launched { + /// The verified process, held independently of the child handle used during spawn. + pub process: Process, + /// The preparation's output files and endpoints. + pub outputs: VmOutputs, + /// The control credential, never written to disk. + pub capability: Capability, +} + +/// Starts a prepared cold launch and returns its verified, detached process. +/// +/// The capability reaches OpenVMM only on standard input, followed by end-of-file. The opened +/// log becomes standard output and error without reopening its path. Only the preparation's +/// owned inheritance allowlist and standard streams reach the child. +/// +/// On Linux the child starts in a new session, with the caller's umask without its owner's bits; +/// the caller's umask is unchanged. On Windows it starts without a console, in its log-named +/// job, outside the caller's job when the runtime requests breakaway. A taken or untrusted log +/// job name is refused. Other processes started during its inheritance window never join that +/// job, but may inherit its handles: Windows callers coordinate their own other spawns, for +/// example with their own lock around this function and their other process starts. The +/// library's inheritance lock remains internal to this spawn. +/// +/// The caller keeps the directory and runtime inputs stable through creation and spawn. +/// A failure after child creation terminates only that child and waits for its confirmed exit. +/// Output paths are not removed. Host failures have kind [`ErrorKind::Io`](crate::ErrorKind::Io), +/// retaining their source; an exit-confirmation failure is reported with the original error. +pub fn spawn(prepared: Prepared) -> Result { + spawn_verified(prepared, spawn::Launched::process) +} + +/// Keeps the child owned until its process identity is verified, or its exit is confirmed. +fn spawn_verified( + prepared: Prepared, + verify: impl FnOnce(&spawn::Launched) -> io::Result, +) -> Result { + let Prepared { + program, + working_dir, + windows_breakaway, + capability, + outputs, + log, + arguments, + inherit, + } = prepared; + let command = VmmCommand { + program, + arguments, + working_dir, + breakaway_from_job: windows_breakaway, + inherit: inherit.iter().map(Inherited::new).collect(), + }; + let child = spawn::spawn_vm(&command, capability.as_bytes(), log) + .map_err(|error| Error::io("cannot spawn the VM").with_source(error))?; + let process = match verify(&child) { + Ok(process) => process, + Err(error) => { + let cleanup = child.kill_and_confirm_exit(); + let message = match cleanup { + Ok(()) => "cannot verify the spawned VM".to_owned(), + Err(cleanup) => { + format!("cannot verify the spawned VM; cannot confirm its exit: {cleanup}") + } + }; + return Err(Error::io(message).with_source(error)); + } + }; + child.detach(); + Ok(Launched { + process, + outputs, + capability, + }) +} + /// Refuses an existing output entry without opening, replacing, or removing it. fn unused_output(path: &Path) -> Result<()> { let error = match fs::symlink_metadata(path) { diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index 466b51654..e09ce767c 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -1,4 +1,8 @@ use std::fs; +#[cfg(any(target_os = "linux", windows))] +use std::process::{Command, Stdio}; +#[cfg(any(target_os = "linux", windows))] +use std::time::{Duration, Instant}; use super::*; use crate::ErrorKind; @@ -757,3 +761,435 @@ fn creation_checks_the_complete_actual_command_line_at_the_windows_boundary() { assert_eq!(fs::read(&input).unwrap(), b"borrowed input"); assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 1); } + +#[cfg(any(target_os = "linux", windows))] +fn stand_in(dir: &VmDir) -> Prepared { + #[cfg(target_os = "linux")] + let (program, arguments) = (PathBuf::from("sleep"), vec![OsString::from("60")]); + #[cfg(windows)] + let (program, arguments) = ( + PathBuf::from(std::env::var_os("SystemRoot").unwrap()).join("System32\\PING.EXE"), + ["-n", "60", "127.0.0.1"] + .into_iter() + .map(OsString::from) + .collect(), + ); + let runtime = Runtime { + openvmm: program, + ..cold_runtime() + }; + let mut prepared = create(&runtime, &cold_config(), None, dir).unwrap(); + prepared.arguments = arguments; + prepared +} + +#[cfg(any(target_os = "linux", windows))] +struct RunningVm(Launched); + +#[cfg(any(target_os = "linux", windows))] +impl Drop for RunningVm { + fn drop(&mut self) { + let _ = self.0.process.kill(); + let _ = self + .0 + .process + .wait(Instant::now() + Duration::from_secs(10)); + } +} + +#[cfg(any(target_os = "linux", windows))] +#[test] +fn spawn_returns_the_same_outputs_and_capability_with_a_verified_process() { + let (_directory, vm) = vm_dir(); + let prepared = stand_in(&vm); + let outputs = prepared.outputs().clone(); + let capability = *prepared.capability().as_bytes(); + let launched = RunningVm(spawn(prepared).unwrap()); + assert_eq!(launched.0.outputs, outputs); + assert_eq!(launched.0.capability.as_bytes(), &capability); + let reference = launched.0.process.reference(); + assert_ne!(reference.pid, std::process::id()); + let reopened = Process::open(reference).unwrap().unwrap(); + assert_eq!(reopened.reference(), reference); + assert!(!reopened.has_exited().unwrap()); + assert!(!format!("{:?}", launched.0).contains(&format!("{capability:?}"))); + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 1); +} + +#[cfg(any(target_os = "linux", windows))] +#[test] +fn a_failure_after_child_creation_terminates_only_that_child_and_confirms_its_exit() { + let (_directory, vm) = vm_dir(); + let (_foreign_directory, foreign_vm) = vm_dir(); + let foreign = RunningVm(spawn(stand_in(&foreign_vm)).unwrap()); + let mut process = None; + let error = spawn_verified(stand_in(&vm), |child| { + process = Some(child.process().unwrap()); + assert!(!process.as_ref().unwrap().has_exited().unwrap()); + Err(io::Error::other("injected process verification failure")) + }) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io); + assert_eq!( + std::error::Error::source(&error).unwrap().to_string(), + "injected process verification failure" + ); + assert!(process.unwrap().has_exited().unwrap()); + assert!(!foreign.0.process.has_exited().unwrap()); + assert!(vm.path().join(LOG).exists()); +} + +#[cfg(any(target_os = "linux", windows))] +#[test] +fn spawn_failure_keeps_caller_owned_files_and_reports_the_host_error() { + let (_directory, vm) = vm_dir(); + let mut prepared = stand_in(&vm); + prepared.program = vm.path().join("absent-openvmm"); + let input = vm.path().join("caller-input"); + fs::write(&input, b"caller-owned").unwrap(); + let error = spawn(prepared).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io); + let source = std::error::Error::source(&error) + .unwrap() + .downcast_ref::() + .unwrap(); + assert_eq!(source.kind(), io::ErrorKind::NotFound); + assert_eq!(fs::read(&input).unwrap(), b"caller-owned"); + assert!(vm.path().join(LOG).exists()); +} + +#[cfg(any(target_os = "linux", windows))] +#[test] +fn spawn_passes_the_opened_log_and_owned_resources_without_reopening_paths() { + let (_directory, vm) = vm_dir(); + let mut prepared = stand_in(&vm); + let original_log = vm.path().join("original-log"); + fs::rename(&prepared.outputs.log, &original_log).unwrap(); + fs::write(&prepared.outputs.log, b"replacement-log").unwrap(); + let input = vm.path().join("caller-input"); + let file = File::create(&input).unwrap(); + let retained = file.try_clone().unwrap(); + #[cfg(target_os = "linux")] + let value = std::os::fd::AsRawFd::as_raw_fd(&file); + #[cfg(windows)] + let value = std::os::windows::io::AsRawHandle::as_raw_handle(&file) as usize; + prepared.inherit(file); + let original_input = vm.path().join("original-input"); + fs::rename(&input, &original_input).unwrap(); + fs::write(&input, b"replacement-input").unwrap(); + let launched = RunningVm( + spawn_verified(prepared, |child| { + #[cfg(target_os = "linux")] + { + let info = fs::read_to_string(format!("/proc/self/fdinfo/{value}")).unwrap(); + let flags = info + .lines() + .find_map(|line| line.strip_prefix("flags:")) + .map(|flags| u32::from_str_radix(flags.trim(), 8).unwrap()) + .unwrap(); + assert_ne!(flags & nix::libc::O_CLOEXEC as u32, 0); + } + child.process() + }) + .unwrap(), + ); + let pid = launched.0.process.reference().pid; + #[cfg(target_os = "linux")] + { + let object = |descriptor| fs::read_link(format!("/proc/{pid}/fd/{descriptor}")).unwrap(); + assert_eq!(object(value), original_input); + assert_eq!(object(1), original_log); + assert_eq!(object(2), original_log); + drop(retained); + } + #[cfg(windows)] + { + assert!(platform::tests::inherits_file(pid, value, &retained)); + let deadline = Instant::now() + Duration::from_secs(10); + while fs::metadata(&original_log).unwrap().len() == 0 { + assert!( + Instant::now() < deadline, + "the opened log received no output" + ); + std::thread::sleep(Duration::from_millis(25)); + } + } + assert_eq!(fs::read(&input).unwrap(), b"replacement-input"); + assert_eq!( + fs::read(&launched.0.outputs.log).unwrap(), + b"replacement-log" + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn library_spawns_preserve_the_callers_umask_and_clear_only_the_childs_owner_bits() { + let umask_in = |path: &str| { + fs::read_to_string(path) + .unwrap() + .lines() + .find_map(|line| line.strip_prefix("Umask:")) + .map(|mask| u32::from_str_radix(mask.trim(), 8).unwrap()) + .unwrap() + }; + for mask in [ + 0o002, 0o022, 0o027, 0o077, 0o100, 0o200, 0o400, 0o477, 0o777, + ] { + let (_directory, vm) = vm_dir(); + let launched = std::thread::scope(|scope| { + scope + .spawn(|| { + nix::sched::unshare(nix::sched::CloneFlags::CLONE_FS).unwrap(); + nix::sys::stat::umask(nix::sys::stat::Mode::from_bits_truncate(mask)); + let launched = RunningVm(spawn(stand_in(&vm)).unwrap()); + assert_eq!(umask_in("/proc/thread-self/status"), mask); + launched + }) + .join() + .unwrap() + }); + assert_eq!( + umask_in(&format!( + "/proc/{}/status", + launched.0.process.reference().pid + )), + mask & 0o077 + ); + } +} + +#[cfg(any(target_os = "linux", windows))] +#[test] +fn a_library_vm_outlives_the_caller_that_spawned_it_without_retaining_its_output_pipe() { + let directory = tempfile::tempdir().unwrap(); + let vm = directory.path().join("vm"); + let output = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "host::vm::tests::spawn_caller_helper", + "--ignored", + "--nocapture", + ]) + .env("NVX_VM_SPAWN_CALLER_DIR", &vm) + .stdin(Stdio::null()) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let reference = + serde_json::from_slice(&fs::read(vm.join("caller-process.json")).unwrap()).unwrap(); + let process = Process::open(&reference).unwrap().unwrap(); + let launched = RunningVm(Launched { + process, + outputs: VmDir::open(&vm).unwrap().outputs(false).unwrap(), + capability: Capability([0; CAPABILITY_LEN]), + }); + assert!(!launched.0.process.has_exited().unwrap()); +} + +#[cfg(any(target_os = "linux", windows))] +#[test] +#[ignore = "invoked in a separate caller by the caller-survival test"] +fn spawn_caller_helper() { + let path = PathBuf::from(std::env::var_os("NVX_VM_SPAWN_CALLER_DIR").unwrap()); + create_private_dir(&path).unwrap(); + let dir = VmDir::open(&path).unwrap(); + let launched = spawn(stand_in(&dir)).unwrap(); + let reference = serde_json::to_vec(launched.process.reference()).unwrap(); + if let Err(error) = fs::write(path.join("caller-process.json"), reference) { + launched.process.kill().unwrap(); + assert!( + launched + .process + .wait(Instant::now() + Duration::from_secs(10)) + .unwrap() + ); + panic!("cannot record the helper's child: {error}"); + } + drop(launched); +} + +#[cfg(windows)] +#[test] +fn library_spawns_refuse_taken_and_untrusted_log_job_names() { + for trusted in [true, false] { + let (_directory, vm) = vm_dir(); + let prepared = stand_in(&vm); + let held = if trusted { + platform::create_log_job(prepared.log()).unwrap().unwrap() + } else { + platform::tests::untrusted_log_job(prepared.log()) + }; + let error = + spawn_verified(prepared, |_| panic!("a refused job started a child")).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io); + let source = std::error::Error::source(&error) + .unwrap() + .downcast_ref::() + .unwrap(); + assert_eq!( + source.kind(), + if trusted { + io::ErrorKind::AlreadyExists + } else { + io::ErrorKind::PermissionDenied + } + ); + drop(held); + } +} + +#[cfg(windows)] +#[test] +fn an_unrelated_process_started_in_the_library_spawn_window_gets_no_vm_claim() { + struct ChildGuard(std::process::Child); + impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + let (_directory, vm) = vm_dir(); + let prepared = stand_in(&vm); + let log = prepared.outputs.log.clone(); + let identity = platform::file_identity(&log).unwrap(); + let program = prepared.program.clone(); + let arguments = prepared.arguments.clone(); + let (entered, in_window) = std::sync::mpsc::channel(); + let (release, released) = std::sync::mpsc::channel(); + let thread = std::thread::spawn(move || { + platform::tests::with_spawn_window( + move || { + entered.send(()).unwrap(); + released.recv_timeout(Duration::from_secs(10)).unwrap(); + }, + || spawn(prepared), + ) + }); + in_window.recv_timeout(Duration::from_secs(10)).unwrap(); + let mut other = ChildGuard( + Command::new(program) + .args(arguments) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + release.send(()).unwrap(); + let launched = RunningVm(thread.join().unwrap().unwrap()); + assert!(platform::log_claim_held(&log, identity).unwrap()); + launched.0.process.kill().unwrap(); + assert!( + launched + .0 + .process + .wait(Instant::now() + Duration::from_secs(10)) + .unwrap() + ); + assert!(other.0.try_wait().unwrap().is_none()); + assert!(!platform::log_claim_held(&log, identity).unwrap()); +} + +#[cfg(any(target_os = "linux", windows))] +#[test] +#[ignore = "needs exact current OpenVMM, kernel and initramfs paths and an available hypervisor"] +fn current_cold_spawn_boots_a_guest_accepts_shell_input_and_exits_cleanly() { + use sha2::{Digest, Sha256}; + + let path = + |name| PathBuf::from(std::env::var_os(name).expect("missing explicit VM proof input")); + let openvmm = path("NVX_VM_SPAWN_OPENVMM"); + let kernel = path("NVX_VM_SPAWN_KERNEL"); + let initramfs = path("NVX_VM_SPAWN_INITRAMFS"); + let identity = |path: &Path| { + ArtifactIdentity::sha256(&format!("{:x}", Sha256::digest(fs::read(path).unwrap()))).unwrap() + }; + let runtime = Runtime { + identity: RuntimeIdentity::new(identity(&openvmm), identity(&kernel), identity(&initramfs)), + openvmm, + kernel, + initramfs, + cpu_profile: Some("auto".to_owned()), + ..cold_runtime() + }; + let path = path("NVX_VM_SPAWN_PROOF_DIR"); + assert!( + !path.exists(), + "a cold-spawn proof needs a fresh owned directory" + ); + create_private_dir(&path).unwrap(); + let vm = VmDir::open(&path).unwrap(); + let config = VmConfig { + boot: Some(Boot { + command_line: "quiet loglevel=0".to_owned(), + capture: None, + }), + ..cold_config() + }; + let prepared = create(&runtime, &config, None, &vm).unwrap(); + let capability = *prepared.capability().as_bytes(); + let launched = RunningVm(spawn(prepared).unwrap()); + assert_eq!(launched.0.capability.as_bytes(), &capability); + let reference = launched.0.process.reference(); + let reopened = Process::open(reference).unwrap().unwrap(); + assert_eq!(reopened.reference(), reference); + let deadline = Instant::now() + Duration::from_secs(60); + let mut console = loop { + match platform::connect_endpoint( + launched.0.outputs.console.as_deref().unwrap(), + reference.pid, + Duration::from_millis(50), + ) { + Ok(console) => break console, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound + | io::ErrorKind::ConnectionRefused + | io::ErrorKind::WouldBlock + ) && Instant::now() < deadline => + { + assert!(!launched.0.process.has_exited().unwrap()); + std::thread::sleep(Duration::from_millis(25)); + } + Err(error) => panic!("cannot attach the spawned VM's console: {error}"), + } + }; + let read_until = |console: &mut dyn platform::Transport, marker: &[u8]| { + let mut received = Vec::new(); + while !received.windows(marker.len()).any(|part| part == marker) { + assert!( + Instant::now() < deadline, + "guest output: {}", + String::from_utf8_lossy(&received) + ); + let mut buffer = [0; 4096]; + match console.read(&mut buffer, Some(Duration::from_millis(100))) { + Ok(0) => panic!("console ended before {}", String::from_utf8_lossy(marker)), + Ok(count) => received.extend_from_slice(&buffer[..count]), + Err(error) if error.kind() == io::ErrorKind::TimedOut => {} + Err(error) => panic!("cannot read the guest console: {error}"), + } + assert!(received.len() <= 1024 * 1024); + } + println!("{}", String::from_utf8_lossy(&received)); + }; + read_until(&mut *console, b"NVX-GUEST-BOOT-OK: alpine"); + console + .write_all( + b"printf 'NVX-SPAWN-COLD-%s\\n' OK; /sbin/nvx-exit 0\n", + Some(Duration::from_secs(5)), + ) + .unwrap(); + read_until(&mut *console, b"NVX-SPAWN-COLD-OK"); + assert!(launched.0.process.wait(deadline).unwrap()); + assert!(reopened.has_exited().unwrap()); + let outcome = crate::host::report::read(&launched.0.outputs.outcome).unwrap(); + assert_eq!(outcome.category.as_deref(), Some("success")); + assert_eq!(outcome.status_code, Some(0)); + println!("NVX-LIBRARY-COLD-SPAWN-EXIT-OK: {}", reference.pid); +} From 0cf131612505976db1686d8e135ac046d18d6a1b Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sun, 8 Nov 2026 17:00:06 -0800 Subject: [PATCH 2/2] host: resolve prepared VM inputs before spawning Resolve caller-relative executable, kernel, initramfs, root and scratch paths before rendering and checking the final launch command. Honor the configured hypervisor in the real-VM fixture and reuse the existing bounded job-claim release check without weakening the unrelated-child assertion. Cover relative inputs outside the VM directory and preserve required-path errors, log rollback and legacy spawn behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d5e2370f-2500-4250-a1da-70d1661912e1 --- nvx-rs/README.md | 8 +- nvx-rs/src/host/platform/tests.rs | 2 +- nvx-rs/src/host/platform/windows/tests.rs | 2 +- nvx-rs/src/host/vm.rs | 29 +++++- nvx-rs/src/host/vm/tests.rs | 111 ++++++++++++++++++++-- 5 files changed, 136 insertions(+), 16 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index c8850c7ba..bc5bde8a3 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -505,7 +505,10 @@ these names before creating any output, including the log. before a spawn. `log()` returns the same opened log, without reopening its path. `inherit(file)` moves an already opened resource into the preparation's inheritance allowlist and keeps it open. `arguments()` returns the whole -configuration's OpenVMM arguments. Creation checks the complete Windows +configuration's OpenVMM arguments. Creation makes caller-relative runtime +and block-image paths absolute without resolving links or `..` components, +so spawning in the VM directory does not change which inputs they name. +Creation checks the complete Windows command line with the actual runtime and VM output paths and no reserve, including quoting and the terminating NUL, on every host. Callers keep the directory and runtime inputs stable through creation and a later spawn. @@ -534,7 +537,8 @@ The [VM tests](src/host/vm/tests.rs) cover cleanup after child creation, ownership, caller survival, the Linux umask, and Windows job claims. Their ignored cold-spawn smoke test runs the current library against explicit `NVX_VM_SPAWN_OPENVMM`, `NVX_VM_SPAWN_KERNEL`, and `NVX_VM_SPAWN_INITRAMFS` -paths, using a fresh `NVX_VM_SPAWN_PROOF_DIR`. It checks a guest boot marker, +paths, using a fresh `NVX_VM_SPAWN_PROOF_DIR` and `NVX_HYPERVISOR` or the +platform's default hypervisor. It checks a guest boot marker, responsive shell input, and a verified clean exit; it downloads no artifact. ### `nvx::host::snapshot` diff --git a/nvx-rs/src/host/platform/tests.rs b/nvx-rs/src/host/platform/tests.rs index 637b3053e..100607c0f 100644 --- a/nvx-rs/src/host/platform/tests.rs +++ b/nvx-rs/src/host/platform/tests.rs @@ -6,7 +6,7 @@ pub(crate) use super::linux::tests::{ pub(crate) use super::windows::tests::set_access; #[cfg(windows)] pub(crate) use super::windows::tests::{ - access, inherits_file, untrusted_log_job, user_sid, with_spawn_window, + access, claim_released_within, inherits_file, untrusted_log_job, user_sid, with_spawn_window, }; use super::*; diff --git a/nvx-rs/src/host/platform/windows/tests.rs b/nvx-rs/src/host/platform/windows/tests.rs index 8a82a73c5..67c72a317 100644 --- a/nvx-rs/src/host/platform/windows/tests.rs +++ b/nvx-rs/src/host/platform/windows/tests.rs @@ -671,7 +671,7 @@ impl HeldSpawn { /// Returns whether the claim on the log `log`, the file `identity`, is released within /// `timeout`. -fn claim_released_within(log: &Path, identity: (u64, u64), timeout: Duration) -> bool { +pub(crate) fn claim_released_within(log: &Path, identity: (u64, u64), timeout: Duration) -> bool { let deadline = Instant::now() + timeout; loop { if !log_claim_held(log, identity).unwrap() { diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index b0404013a..04b9cd90f 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -16,7 +16,7 @@ use std::fs::{self, File, OpenOptions}; use std::io; use std::path::{Path, PathBuf}; -use crate::host::config::{ConsoleAttachment, VmConfig}; +use crate::host::config::{ConsoleAttachment, RootConfig, UpperConfig, VmConfig}; use crate::host::filesystem; use crate::host::paths::{absolute, normalized}; use crate::host::platform; @@ -252,7 +252,8 @@ impl Prepared { /// /// A cold launch needs [`VmConfig::boot`]. Restoring `from` a [`Snapshot`] is not supported yet. /// The caller keeps the directory and runtime inputs stable during creation and a later spawn. -/// Runtime and configuration paths are used as supplied; output paths come from `dir`. +/// Caller-relative runtime and block-image paths become absolute during creation, without +/// resolving links or `..` components; output paths come from `dir`. /// /// The log is created anew, privately, and kept open in [`Prepared`]. Other outputs are reserved /// names that OpenVMM creates when it runs: existing entries at those names are refused, not @@ -302,6 +303,28 @@ pub fn create( } } } + let resolve = |path: &Path| -> Result { + // Keep invalid spellings for the renderer's existing required-path checks. + if path.as_os_str().is_empty() || path.as_os_str().to_string_lossy().contains('\0') { + return Ok(path.to_owned()); + } + absolute(path).map_err(|error| { + Error::io(format!("cannot resolve VM input {}", path.display())).with_source(error) + }) + }; + let runtime = Runtime { + openvmm: resolve(&runtime.openvmm)?, + kernel: resolve(&runtime.kernel)?, + initramfs: resolve(&runtime.initramfs)?, + ..runtime.clone() + }; + let mut config = config.clone(); + if let RootConfig::Layered(root) = &mut config.root { + root.image = resolve(&root.image)?; + if let UpperConfig::Scratch { path, .. } = &mut root.upper { + *path = resolve(path)?; + } + } let mut options = OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] @@ -336,7 +359,7 @@ pub fn create( } let capability = Capability(crate::host::instance::generate_capability()?); let arguments = config.openvmm_arguments( - runtime, + &runtime, &outputs.endpoint, &outputs.outcome, outputs.console.as_deref(), diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index e09ce767c..38592dedd 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -6,12 +6,14 @@ use std::time::{Duration, Instant}; use super::*; use crate::ErrorKind; -use crate::activation::{MachineIdMode, View}; +use crate::activation::{Journal, MachineIdMode, OverlayMode, View}; use crate::host::config::{ - Boot, PROCESS_COMMAND_LINE_LIMIT, RootConfig, process_command_line_units, + Boot, LayeredRoot, PROCESS_COMMAND_LINE_LIMIT, RootConfig, UpperConfig, + process_command_line_units, }; use crate::host::runtime::{ArtifactIdentity, LaunchOptions, RuntimeIdentity}; use crate::hypervisor::Hypervisor; +use crate::protocol::root::RootImage; fn is_fresh_pipe(endpoint: &str, prefix: &str) -> bool { endpoint @@ -432,9 +434,9 @@ fn cold_config() -> VmConfig { fn cold_runtime() -> Runtime { let identity = ArtifactIdentity::new("test-runtime").unwrap(); Runtime { - openvmm: PathBuf::from("missing-openvmm"), - kernel: PathBuf::from("missing-kernel"), - initramfs: PathBuf::from("missing-initramfs"), + openvmm: absolute(Path::new("missing-openvmm")).unwrap(), + kernel: absolute(Path::new("missing-kernel")).unwrap(), + initramfs: absolute(Path::new("missing-initramfs")).unwrap(), identity: RuntimeIdentity::new(identity.clone(), identity.clone(), identity), hypervisor: if cfg!(windows) { Hypervisor::Whp @@ -763,17 +765,23 @@ fn creation_checks_the_complete_actual_command_line_at_the_windows_boundary() { } #[cfg(any(target_os = "linux", windows))] -fn stand_in(dir: &VmDir) -> Prepared { +fn stand_in_command() -> (PathBuf, Vec) { #[cfg(target_os = "linux")] - let (program, arguments) = (PathBuf::from("sleep"), vec![OsString::from("60")]); + let command = (PathBuf::from("/bin/sleep"), vec![OsString::from("60")]); #[cfg(windows)] - let (program, arguments) = ( + let command = ( PathBuf::from(std::env::var_os("SystemRoot").unwrap()).join("System32\\PING.EXE"), ["-n", "60", "127.0.0.1"] .into_iter() .map(OsString::from) .collect(), ); + command +} + +#[cfg(any(target_os = "linux", windows))] +fn stand_in(dir: &VmDir) -> Prepared { + let (program, arguments) = stand_in_command(); let runtime = Runtime { openvmm: program, ..cold_runtime() @@ -797,6 +805,87 @@ impl Drop for RunningVm { } } +#[cfg(any(target_os = "linux", windows))] +#[test] +fn spawn_resolves_relative_runtime_and_block_inputs_outside_the_vm_directory() { + let cwd = std::env::current_dir().unwrap(); + let inputs = tempfile::tempdir_in(&cwd).unwrap(); + let relative = inputs.path().strip_prefix(&cwd).unwrap(); + let (program, arguments) = stand_in_command(); + let executable = format!("stand-in{}", std::env::consts::EXE_SUFFIX); + fs::copy(program, inputs.path().join(&executable)).unwrap(); + for name in ["kernel", "initramfs", "image.erofs", "scratch.raw"] { + fs::write(inputs.path().join(name), name.as_bytes()).unwrap(); + } + let root = |image, scratch| { + RootConfig::Layered(LayeredRoot { + image, + format: RootImage::erofs(None), + gpt_first_partition_bound: None, + upper: UpperConfig::Scratch { + path: scratch, + journal: Journal::Forbidden, + }, + overlay: OverlayMode::TrustedLowers, + }) + }; + let runtime = Runtime { + openvmm: relative.join(&executable), + kernel: relative.join("kernel"), + initramfs: relative.join("initramfs"), + ..cold_runtime() + }; + let config = VmConfig { + root: root(relative.join("image.erofs"), relative.join("scratch.raw")), + ..cold_config() + }; + let (_directory, vm) = vm_dir(); + let mut prepared = create(&runtime, &config, None, &vm).unwrap(); + let resolved_runtime = Runtime { + openvmm: inputs.path().join(&executable), + kernel: inputs.path().join("kernel"), + initramfs: inputs.path().join("initramfs"), + ..runtime + }; + let resolved_config = VmConfig { + root: root( + inputs.path().join("image.erofs"), + inputs.path().join("scratch.raw"), + ), + ..config + }; + assert_eq!(prepared.program, resolved_runtime.openvmm); + assert_eq!( + prepared.arguments(), + resolved_config + .openvmm_arguments( + &resolved_runtime, + &prepared.outputs().endpoint, + &prepared.outputs().outcome, + prepared.outputs().console.as_deref(), + ) + .unwrap() + ); + prepared.arguments = arguments; + let launched = RunningVm(spawn(prepared).unwrap()); + assert!(!launched.0.process.has_exited().unwrap()); +} + +#[test] +fn resolving_inputs_preserves_required_path_errors_and_log_rollback() { + for kernel in [PathBuf::new(), PathBuf::from("bad\0kernel")] { + let (_directory, vm) = vm_dir(); + let runtime = Runtime { + kernel, + ..cold_runtime() + }; + let error = create(&runtime, &cold_config(), None, &vm).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert!(error.to_string().contains("Runtime.kernel"), "{error}"); + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 0); + } +} + #[cfg(any(target_os = "linux", windows))] #[test] fn spawn_returns_the_same_outputs_and_capability_with_a_verified_process() { @@ -1090,8 +1179,11 @@ fn an_unrelated_process_started_in_the_library_spawn_window_gets_no_vm_claim() { .wait(Instant::now() + Duration::from_secs(10)) .unwrap() ); + assert!( + platform::tests::claim_released_within(&log, identity, Duration::from_secs(10)), + "an unrelated process retained the VM's job claim" + ); assert!(other.0.try_wait().unwrap().is_none()); - assert!(!platform::log_claim_held(&log, identity).unwrap()); } #[cfg(any(target_os = "linux", windows))] @@ -1113,6 +1205,7 @@ fn current_cold_spawn_boots_a_guest_accepts_shell_input_and_exits_cleanly() { openvmm, kernel, initramfs, + hypervisor: Hypervisor::from_env_or_default().unwrap(), cpu_profile: Some("auto".to_owned()), ..cold_runtime() };