From bce5ad0354da01dc2b91f2f46cd2e1e11afcc8b2 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 01:31:08 -0800 Subject: [PATCH 1/6] guest: apply the VM view through its root Set the VM hostname from its view bytes and reuse the machine ID and single-server resolver formats. Keep every view bind, including failed attachments, with the root so teardown removes them before the switch and leaves them mounted afterwards. Preserve existing entry and activation callers through narrow re-exports and shared operations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f407efdc-4592-4fb9-8abc-095402b6df7b Copilot-Session: a773baad-9362-4d2f-be37-d4f9eda25cee --- nvx-rs/README.md | 22 ++- nvx-rs/public-api/linux.txt | 11 ++ nvx-rs/src/guest/clone/activation/apply.rs | 37 +---- nvx-rs/src/guest/entry.rs | 99 +++--------- nvx-rs/src/guest/entry/tests.rs | 75 --------- nvx-rs/src/guest/mod.rs | 5 +- nvx-rs/src/guest/root.rs | 95 ++++++++++- nvx-rs/src/guest/root/tests.rs | 1 + nvx-rs/src/guest/root/tests/privileged.rs | 177 ++++++++++++++++++++ nvx-rs/src/guest/root/tests/view.rs | 180 +++++++++++++++++++++ nvx-rs/src/guest/sys/entry.rs | 4 +- nvx-rs/src/guest/sys/mod.rs | 2 +- nvx-rs/src/guest/view.rs | 149 +++++++++++++++++ nvx-rs/src/guest/view/tests.rs | 101 ++++++++++++ 14 files changed, 763 insertions(+), 195 deletions(-) create mode 100644 nvx-rs/src/guest/root/tests/view.rs create mode 100644 nvx-rs/src/guest/view.rs create mode 100644 nvx-rs/src/guest/view/tests.rs diff --git a/nvx-rs/README.md b/nvx-rs/README.md index db0bc0b8b..f4fae8f37 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -156,6 +156,7 @@ changes only when the module's purpose does. | [`nvx::guest::clone`](#nvxguestclone) | x86_64: the preparation of a template's guest for a capture, and the repair and activation of each clone | | [`nvx::guest::reseed`](#nvxguestreseed) | A restore's fresh entropy, and the reseed of the kernel's random number generator with it | | [`nvx::guest::root`](#nvxguestroot) | The operations of a layered root: its bootstrap, assembly, shares, export, mappings, and teardown | +| [`nvx::guest::view`](#nvxguestview) | The VM-wide hostname, machine ID, and single-server resolver, whose binds the layered root owns | | [`nvx::guest::gpt`](#nvxguestgpt) | `read_disk`: the partition table of a GPT disk, read and checked through its block device | GPT lower layers use `noload,errors=remount-ro`: the kernel neither loads nor @@ -1905,7 +1906,7 @@ refused, and dropping the root unmounts nothing, while descriptors opened before the switch, such as a `TargetRoot` and a `SourceRoot`, still map. A move that fails leaves the root where it was. -`Root::teardown`: the shares and mappings in reverse order, a runtime mount whose +`Root::teardown`: the view's binds, then the shares and mappings in reverse order, a runtime mount whose attachment failed and which could not be detached, then the mounts of an aggregate, of the export, and of the runtime, the later first, the overlay, the layers from the top down, and the upper, with every failed sync or unmount @@ -1919,6 +1920,25 @@ operations collect no child's status, change the caller's root only by the switch and never its credentials, start no async runtime, and never power the VM off. +### `nvx::guest::view` + +Hidden, unstable internal reference code of the guest's space, like `root`. +`apply(&mut Root, &activation::View, dns)` sets the current UTS namespace's +hostname from the view's bytes. Image mode leaves `/etc/machine-id` untouched; +generated mode writes a new UUID at `MACHINE_ID_FILE` in the runtime directory +and binds it read-only over the image's ID. Optional DNS writes one server in +`RESOLVER_FILE` and binds it read-only over `/etc/resolv.conf`. The existing +`entry::MachineId` and hostname constants, and activation's file constants, +remain available through re-exports. + +The root owns every bind, including an attachment left by a failed call. +Teardown before the switch removes them; after the switch teardown refuses +and drop unmounts nothing. A view is attempted once per root, before its +switch. Refusals return the crate's `Error` with kind `Unsupported`, and +system failures with kind `Io`; an error does not restore the old hostname +or remove generated files. The activation record's hostname limit and +machine-ID modes are unchanged. + ### `nvx::guest::gpt` Hidden, like `nvx::guest::root`, which reads its disks through it: NVX's diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index a89d94a41..164361fdd 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -1657,6 +1657,17 @@ method nvx::guest::time::Violation::status fn nvx::guest::time::main (hidden) mod nvx::guest::time_probe (hidden) fn nvx::guest::time_probe::main (hidden) +mod nvx::guest::view (hidden) +const nvx::guest::view::DEFAULT_HOSTNAME (hidden) +const nvx::guest::view::MACHINE_ID_FILE (hidden) +const nvx::guest::view::MAX_HOSTNAME_BYTES (hidden) +enum nvx::guest::view::MachineId (hidden) +variant nvx::guest::view::MachineId::Generated (hidden) +field nvx::guest::view::MachineId::Generated::0 (hidden) +variant nvx::guest::view::MachineId::Image (hidden) +method nvx::guest::view::MachineId::generate (hidden) +const nvx::guest::view::RESOLVER_FILE (hidden) +fn nvx::guest::view::apply (hidden) fn nvx::guest_path mod nvx::host mod nvx::host::artifacts diff --git a/nvx-rs/src/guest/clone/activation/apply.rs b/nvx-rs/src/guest/clone/activation/apply.rs index 54934ca50..d67dfca27 100644 --- a/nvx-rs/src/guest/clone/activation/apply.rs +++ b/nvx-rs/src/guest/clone/activation/apply.rs @@ -10,10 +10,8 @@ mod tests; use std::fmt; -use std::fs; use std::io; use std::net::{IpAddr, Ipv4Addr}; -use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -23,24 +21,15 @@ use super::super::Devices; use super::Activation; use crate::activation::{self, ActivationRecord, Configuration, MachineIdMode, Publication}; use crate::gpt::Expected; -use crate::guest::entry::{self, Entry, Identity, MachineId, RuntimeBinds}; +use crate::guest::entry::{self, Entry, Identity, RuntimeBinds}; use crate::guest::ingress::{Announcer, StartError}; use crate::guest::mappings::{self, Access, SourceRoot}; use crate::guest::root::{self, Assembly, AssemblyError, Device, Root, TeardownError}; +use crate::guest::view::{MachineId, write_resolver_file}; +#[cfg(doc)] use crate::protocol::cmdline::write_resolver; -/// The name of the file, in the template's runtime directory, to which the activation's -/// application writes the machine ID that it generates for a record that asks for one, as NVX's -/// managed agent writes its own to `/run/nvx/workload-machine-id`. -pub const MACHINE_ID_FILE: &str = "workload-machine-id"; - -/// The name of the file, in the template's runtime directory, to which the activation's -/// application writes the resolver file that names the DNS server of a record's network, which -/// every execution sees read-only at `/etc/resolv.conf`. -pub const RESOLVER_FILE: &str = "resolv.conf"; - -/// The mode of the resolver file, whatever the umask: every execution may read it. -const RESOLVER_MODE: u32 = 0o444; +pub use crate::guest::view::{MACHINE_ID_FILE, RESOLVER_FILE}; /// The inputs of a clone's activation that its guest takes from its own configuration, which its /// template captured, so that every clone of the template has the same: the runtime directory, @@ -919,24 +908,6 @@ fn teardown_with( Err(AppliedTeardownError { binds, root }) } -/// Writes a resolver file that names `server` as the guest's one DNS server, as [`write_resolver`] -/// writes one, to a new file at `path`, with mode 0444 whatever the process's umask, so that -/// every execution may read it. -/// -/// A file, or a symbolic link, at `path` already fails the call with `AlreadyExists`, and is left -/// alone, so each activation writes its resolver file once. -fn write_resolver_file(path: &Path, server: IpAddr) -> io::Result<()> { - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(RESOLVER_MODE) - .open(path)?; - // The umask narrows the mode that creates the file, which could keep executions of other - // users from reading it. - file.set_permissions(fs::Permissions::from_mode(RESOLVER_MODE))?; - write_resolver(&mut file, server) -} - /// Returns the assembly of the root that `configuration` sets, with `template`'s runtime /// directory, devices, and timeout. fn assembly(template: &RootTemplate, configuration: &Configuration) -> Assembly { diff --git a/nvx-rs/src/guest/entry.rs b/nvx-rs/src/guest/entry.rs index d373c4514..6bec4e34a 100644 --- a/nvx-rs/src/guest/entry.rs +++ b/nvx-rs/src/guest/entry.rs @@ -135,13 +135,12 @@ use super::parse::parse_decimal; #[cfg(doc)] use super::spawn::{Command, LaunchStage}; use super::sys::{self, EntryChild, Errno, FileDescriptor, Mode, OpenFlags}; +use super::view::{MACHINE_ID_TARGET, RESOLVER_TARGET}; #[cfg(doc)] use crate::protocol::cmdline::write_resolver; -/// The hostname that an entry gives its execution unless the adapter names another. -pub const DEFAULT_HOSTNAME: &str = "nvx-sandbox"; -/// The longest hostname, in bytes: Linux's `HOST_NAME_MAX`. -pub const MAX_HOSTNAME_BYTES: usize = 64; +pub use super::view::{DEFAULT_HOSTNAME, MAX_HOSTNAME_BYTES, MachineId}; + /// The most supplementary groups that an identity holds: Linux's `NGROUPS_MAX`. pub const MAX_GROUPS: usize = 65536; /// The largest account database, `/etc/passwd` or `/etc/group`, that existing-account mode reads. @@ -152,15 +151,9 @@ const PASSWD: &std::ffi::CStr = c"etc/passwd"; const GROUP: &std::ffi::CStr = c"etc/group"; /// The ID that `setresuid` and `setresgid` read as "unchanged", which no identity may hold. const UNCHANGED_ID: u32 = u32::MAX; -/// The mode of a generated machine ID's file. -const MACHINE_ID_MODE: u32 = 0o444; -/// Where [`Entry::bind_runtime`] binds a generated machine ID in the root. -const MACHINE_ID_TARGET: &str = "/etc/machine-id"; /// Where [`Entry::bind_runtime`] binds the time status in the root, as a layered root's /// [`mount_runtime`](super::root::Root::mount_runtime) does. pub(crate) const TIME_STATUS_TARGET: &str = "/run/nvx/time"; -/// Where [`Entry::bind_runtime`] binds the resolver file in the root. -const RESOLVER_TARGET: &str = "/etc/resolv.conf"; /// The names of the Linux capabilities, by number. const CAPABILITY_NAMES: [&str; 41] = [ @@ -438,70 +431,6 @@ impl Identity { } } -/// The machine ID that an execution sees at `/etc/machine-id`. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -#[non_exhaustive] -pub enum MachineId { - /// The image's own `/etc/machine-id`, which the entry leaves untouched. - #[default] - Image, - /// The ID that the regular file at this path holds, such as one that [`MachineId::generate`] - /// wrote once for the boot, which [`Entry::bind_runtime`] binds read-only over - /// `/etc/machine-id`. The bind shows the file as it is, so an ID that the file's owner - /// rewrites in place, as after a restore, reaches every execution. - Generated(PathBuf), -} - -impl MachineId { - /// Generates a machine ID from the kernel's random number generator, writes it to a new file - /// at `path`, with mode 0444 whatever the process's umask, and returns the mode that binds - /// it. - /// - /// The ID is a random version 4 UUID, written as `/etc/machine-id` holds one: 32 lowercase - /// hexadecimal digits and a newline. A file, or a symbolic link, at `path` already fails the - /// call with `AlreadyExists`, so each boot generates its ID once. - pub fn generate(path: impl Into) -> io::Result { - let path = path.into(); - let mut id = [0u8; 16]; - getrandom::fill(&mut id).map_err(|error| { - error - .raw_os_error() - .map_or_else(|| io::Error::other(error), io::Error::from_raw_os_error) - })?; - let mode = Mode::from_bits_retain(MACHINE_ID_MODE); - let file = sys::open( - &sys::path_c_string(&path)?, - OpenFlags::O_WRONLY - | OpenFlags::O_CREAT - | OpenFlags::O_EXCL - | OpenFlags::O_NOFOLLOW - | OpenFlags::O_CLOEXEC, - mode, - )?; - // The umask narrows the mode that creates the file, which could keep executions of - // other users from reading the ID. - file.set_mode(mode)?; - file.write_all(&machine_id_text(id))?; - file.close()?; - Ok(Self::Generated(path)) - } -} - -/// Returns the text of the machine ID made of `id`'s random bits, as a version 4 UUID: 32 -/// lowercase hexadecimal digits and a newline. -fn machine_id_text(mut id: [u8; 16]) -> [u8; 33] { - /// The lowercase hexadecimal digits. - const DIGITS: &[u8; 16] = b"0123456789abcdef"; - id[6] = (id[6] & 0x0f) | 0x40; - id[8] = (id[8] & 0x3f) | 0x80; - let mut text = [b'\n'; 33]; - for (index, byte) in id.iter().enumerate() { - text[2 * index] = DIGITS[usize::from(byte >> 4)]; - text[2 * index + 1] = DIGITS[usize::from(byte & 0x0f)]; - } - text -} - /// Whether an execution's listeners are published, through private-network ingress or the /// host's loopback, which decides whether the entry allows the capabilities of /// [`Capabilities::PUBLISHED_REFUSED`]. @@ -866,10 +795,24 @@ fn bind_file( source: &Path, target: &str, ) -> Result { - match bind_runtime_file(kernel, root, source, target, Kind::File) { - Err(MappingError::Link { path: link }) if link == Path::new(target) => { - remove_link(path, target).map_err(|_| MappingError::Link { path: link })?; - bind_runtime_file(kernel, root, source, target, Kind::File) + bind_file_retained(kernel, root, path, source, target).map_err(|failure| failure.error) +} + +/// Binds a runtime file as [`bind_file`] does, retaining an attachment that could not be detached. +pub(crate) fn bind_file_retained( + kernel: &mut dyn Kernel, + root: &TargetRoot, + path: &Path, + source: &Path, + target: &str, +) -> Result { + match bind_runtime_file_retained(kernel, root, source, target, Kind::File) { + Err(failure) + if matches!(&failure.error, MappingError::Link { path: link } + if link == Path::new(target)) => + { + remove_link(path, target).map_err(|_| failure)?; + bind_runtime_file_retained(kernel, root, source, target, Kind::File) } bound => bound, } diff --git a/nvx-rs/src/guest/entry/tests.rs b/nvx-rs/src/guest/entry/tests.rs index efc43e04d..5f3e002ae 100644 --- a/nvx-rs/src/guest/entry/tests.rs +++ b/nvx-rs/src/guest/entry/tests.rs @@ -472,81 +472,6 @@ fn an_entry_needs_a_root_directory() { ); } -#[test] -fn machine_ids_are_version_4_uuids_in_lowercase_hexadecimal() { - assert_eq!( - &machine_id_text([0; 16]), - b"00000000000040008000000000000000\n" - ); - assert_eq!( - &machine_id_text([0xff; 16]), - b"ffffffffffff4fffbfffffffffffffff\n" - ); - assert_eq!( - &machine_id_text([ - 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, - 0xcd, 0xef - ]), - b"0123456789ab4def8123456789abcdef\n" - ); -} - -#[test] -fn a_machine_id_is_generated_once_per_file() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("machine-id"); - let mode = MachineId::generate(&path).unwrap(); - assert_eq!(mode, MachineId::Generated(path.clone())); - let text = std::fs::read(&path).unwrap(); - assert_eq!(text.len(), 33); - assert_eq!(text[32], b'\n'); - assert!( - text[..32] - .iter() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) - ); - assert_eq!(text[12], b'4'); - assert!(matches!(text[16], b'8' | b'9' | b'a' | b'b')); - assert_eq!( - std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, - 0o444 - ); - let error = MachineId::generate(&path).unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); - assert_eq!(std::fs::read(&path).unwrap(), text); - // A link at the path is never followed. - let target = directory.path().join("target"); - let link = directory.path().join("link"); - std::os::unix::fs::symlink(&target, &link).unwrap(); - assert_eq!( - MachineId::generate(&link).unwrap_err().kind(), - io::ErrorKind::AlreadyExists - ); - assert!(!target.exists()); - let other = directory.path().join("other"); - MachineId::generate(&other).unwrap(); - assert_ne!(std::fs::read(&other).unwrap(), text); -} - -#[test] -fn a_generated_machine_id_has_mode_0444_whatever_the_umask() { - // The umask is the process's, so the test runs alone in a process of its own. - if crate::guest::sys::tests::isolate_process_test( - "NVX_ENTRY_UMASK_TEST_CHILD", - "guest::entry::tests::a_generated_machine_id_has_mode_0444_whatever_the_umask", - ) { - return; - } - nix::sys::stat::umask(sys::Mode::from_bits_truncate(0o077)); - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("machine-id"); - MachineId::generate(&path).unwrap(); - assert_eq!( - std::fs::metadata(&path).unwrap().permissions().mode() & 0o7777, - 0o444 - ); -} - #[test] fn runtime_binds_need_a_mount_root_unless_there_are_none() { let root = root_with(None, None); diff --git a/nvx-rs/src/guest/mod.rs b/nvx-rs/src/guest/mod.rs index 2ffcd754e..7a6286f09 100644 --- a/nvx-rs/src/guest/mod.rs +++ b/nvx-rs/src/guest/mod.rs @@ -20,7 +20,8 @@ //! own. NVX's own space is internal reference code, which NVX's guest programs use: it is hidden //! from this documentation and unstable, and it becomes crate-private once nothing outside NVX //! uses it. It is `root`, which bootstraps the guest for a layered root, assembles the root, -//! mounts live shares and mappings in it, and tears it down; `gpt`, which reads the partition +//! mounts live shares and mappings in it, and tears it down; `view`, which sets the VM's hostname, +//! machine ID, and resolver file, whose binds the root owns; `gpt`, which reads the partition //! table of a GPT disk that the kernel attached as a block device; the mapper of `mappings`, which //! mounts host mappings beneath the descriptors of the roots that it names; and the activation's //! application, `clone::apply` and its types, with which a clone assembles its root and checks @@ -139,3 +140,5 @@ pub mod time; #[cfg(target_arch = "x86_64")] #[doc(hidden)] pub mod time_probe; +#[doc(hidden)] +pub mod view; diff --git a/nvx-rs/src/guest/root.rs b/nvx-rs/src/guest/root.rs index dccf02b95..2d047a2c6 100644 --- a/nvx-rs/src/guest/root.rs +++ b/nvx-rs/src/guest/root.rs @@ -99,7 +99,7 @@ //! - and is not another share's or mapping's target, nor inside one, nor holds one. //! //! The root's [`Mapper`] then attaches the shares beneath the overlay's descriptor, as -//! [`mappings`](super::mappings) describes, with NVX's container root's reserved targets, +//! [`mappings`] describes, with NVX's container root's reserved targets, //! [`Reserved::container_root`](super::mappings::Reserved::container_root): it walks to each //! target through no symbolic link or magic link; creates each missing component only on the //! overlay, or on the runtime's `/run` tmpfs once the root has mounted it, a directory with mode @@ -191,8 +191,9 @@ use std::time::{Duration, Instant}; use self::kernel::{Kernel, SystemKernel}; use super::entry::{self, TIME_STATUS_TARGET}; use super::gpt::DeviceError; -use super::mappings::{Kind, Mapper, MappingError, TargetRoot, below_its_root}; +use super::mappings::{self, Kind, Mapped, Mapper, MappingError, TargetRoot, below_its_root}; use super::sys::{self, Errno, FileDescriptor, FileSystem, Mode, MountFlags, OpenFlags}; +use super::view::{MACHINE_ID_TARGET, MachineId, RESOLVER_TARGET}; use crate::gpt::{Expected, GptError, Partition}; use crate::protocol::root::RAM_UPPER_CAPS; use crate::uuid::Uuid; @@ -645,6 +646,8 @@ fn assemble_with( first_partition: None, shares: Vec::new(), runtime_stray: None, + view_binds: None, + view_stray: None, mapper: None, mapped: Vec::new(), latched: false, @@ -731,6 +734,10 @@ pub struct Root { /// A runtime mount whose attachment failed and which could not be detached during setup or /// its rollback. runtime_stray: Option<(RuntimeMounts, FileDescriptor)>, + /// The view's root descriptor and binds, in bind order, once its application was attempted. + view_binds: Option<(TargetRoot, Vec)>, + /// A view bind whose attachment failed and which could not be detached. + view_stray: Option<(PathBuf, FileDescriptor)>, /// The mapper of the live shares and mappings in the root, once the root has mounted one, whose /// own mounts are the overlay and the runtime's `/run` tmpfs, if the root held it then. mapper: Option, @@ -760,6 +767,57 @@ impl Root { &self.runtime } + /// Reserves the root's one view application before it changes the VM's hostname or files. + pub(crate) fn begin_view(&mut self) -> crate::Result<()> { + if self.switched || self.latched || self.view_binds.is_some() { + return Err(crate::Error::unsupported( + "the root is switched, failed, or its view has already been attempted", + )); + } + let root = TargetRoot::open_with(self.kernel.as_mut(), &self.path).map_err(|error| { + crate::Error::io(format!("failed to open the VM view's root: {error}")) + .with_source(error) + })?; + self.view_binds = Some((root, Vec::new())); + Ok(()) + } + + /// Binds the view's fixed files through the existing entry seam and retains every attachment. + pub(crate) fn bind_view( + &mut self, + machine_id: &MachineId, + resolver: Option<&Path>, + ) -> crate::Result<()> { + let Some((root, binds)) = self.view_binds.as_mut() else { + return Err(crate::Error::unsupported( + "the root's view has not been started", + )); + }; + let machine_id = match machine_id { + MachineId::Image => None, + MachineId::Generated(path) => Some(path.as_path()), + }; + for (source, target) in [(machine_id, MACHINE_ID_TARGET), (resolver, RESOLVER_TARGET)] { + let Some(source) = source else { + continue; + }; + match entry::bind_file_retained(self.kernel.as_mut(), root, &self.path, source, target) + { + Ok(bound) => binds.push(bound), + Err(failure) => { + self.latched = true; + self.view_stray = failure.stray.map(|tree| (PathBuf::from(target), tree)); + return Err(crate::Error::io(format!( + "failed to bind the VM view at {target}: {}", + failure.error + )) + .with_source(failure.error)); + } + } + } + Ok(()) + } + /// Returns whether a live share, the host export, or a mapping failed, after which the root /// refuses every later one, so that its caller starts no workload in it. pub fn is_latched(&self) -> bool { @@ -791,7 +849,7 @@ impl Root { self.overlay_mode } - /// Unmounts the root's mounts, in order: a live share or a mapping that a rename moved and + /// Unmounts the root's mounts, in order: the view's binds, a live share or a mapping that a rename moved and /// that its mapper failed to detach, which the mapper detaches, the live shares and mappings /// in reverse order, a runtime mount whose attachment failed and which could not be detached, /// the mounts of an aggregate live share, of the host export, and of the runtime, the later @@ -1363,7 +1421,34 @@ impl Root { }); } let mut failures = Vec::new(); - // The mapper's descriptor of the root keeps the overlay busy, so it goes first. + if let Some((target, tree)) = self.view_stray.take() + && let Err(error) = self.kernel.detach(&tree) + { + failures.push(Unmount { + mount: Mount::Mapping { + target: target.clone(), + }, + path: self.path.join(target.strip_prefix("/").unwrap_or(&target)), + step: Step::Unmount, + error: error.into(), + }); + } + if let Some((root, binds)) = self.view_binds.take() + && let Err(error) = mappings::unbind_with(self.kernel.as_mut(), &root, &binds) + { + for failure in error.failures() { + let target = failure.target(); + failures.push(Unmount { + mount: Mount::Mapping { + target: target.to_owned(), + }, + path: self.path.join(target.strip_prefix("/").unwrap_or(target)), + step: Step::Unmount, + error: share::io_error(failure.error()), + }); + } + } + // The mapper's descriptor must also close before the overlay is unmounted. if let Some(mut mapper) = self.mapper.take() { let mapped = std::mem::take(&mut self.mapped); for (index, failure) in mapper.unmount_with(self.kernel.as_mut()) { @@ -1456,6 +1541,8 @@ impl fmt::Debug for Root { ) .field("shares", &self.shares) .field("runtime_stray", &self.runtime_stray) + .field("view_binds", &self.view_binds) + .field("view_stray", &self.view_stray) .field("mapped", &self.mapped) .field("latched", &self.latched) .field("switched", &self.switched) diff --git a/nvx-rs/src/guest/root/tests.rs b/nvx-rs/src/guest/root/tests.rs index f7cb119c5..b9f30a342 100644 --- a/nvx-rs/src/guest/root/tests.rs +++ b/nvx-rs/src/guest/root/tests.rs @@ -20,6 +20,7 @@ use crate::guest::sys::MountAttributes; mod privileged; mod runtime; mod switch; +mod view; /// The base address of the distro device's MMIO window. const DISTRO: u64 = 0xd000_0000; diff --git a/nvx-rs/src/guest/root/tests/privileged.rs b/nvx-rs/src/guest/root/tests/privileged.rs index 999891903..46f442635 100644 --- a/nvx-rs/src/guest/root/tests/privileged.rs +++ b/nvx-rs/src/guest/root/tests/privileged.rs @@ -90,6 +90,8 @@ impl Scene { first_partition: None, shares: Vec::new(), runtime_stray: None, + view_binds: None, + view_stray: None, mapper: None, mapped: Vec::new(), latched: false, @@ -448,3 +450,178 @@ fn a_switched_root_keeps_its_mounts_and_maps_through_earlier_descriptors() { }); }); } + +/// Returns a view's root in private mount and UTS namespaces, never the host's UTS namespace. +fn view_scene() -> Option<(Scene, Root)> { + let scene = Scene::new()?; + let before = fs::read_link("/proc/thread-self/ns/uts").unwrap(); + nix::sched::unshare(nix::sched::CloneFlags::CLONE_NEWUTS).unwrap(); + assert_ne!(fs::read_link("/proc/thread-self/ns/uts").unwrap(), before); + let root = scene.root(); + fs::create_dir(root.path().join("etc")).unwrap(); + fs::write(root.path().join("etc/machine-id"), b"image-machine-id\n").unwrap(); + fs::write(root.path().join("etc/resolv.conf"), b"image resolver\n").unwrap(); + Some((scene, root)) +} + +#[test] +#[ignore = "needs root and Linux 6.8 or later"] +fn view_image_mode_applies_decoded_bytes_without_machine_id_or_resolver_binds() { + let Some((scene, mut root)) = view_scene() else { + return; + }; + let view = crate::protocol::view::parse(b"nvx_hostname=766d20ff09 nvx_machine_id=image") + .unwrap() + .unwrap(); + let selected = crate::guest::view::apply(&mut root, &view, None).unwrap(); + assert_eq!(selected, MachineId::Image); + assert_eq!( + fs::read("/proc/sys/kernel/hostname").unwrap(), + [view.hostname.as_slice(), b"\n"].concat() + ); + assert_eq!( + fs::read(root.path().join("etc/machine-id")).unwrap(), + b"image-machine-id\n" + ); + assert_eq!( + fs::read(root.path().join("etc/resolv.conf")).unwrap(), + b"image resolver\n" + ); + assert!(mounts_below(root.path()).is_empty()); + assert!( + !root + .runtime() + .join(crate::guest::view::MACHINE_ID_FILE) + .exists() + ); + assert!( + !root + .runtime() + .join(crate::guest::view::RESOLVER_FILE) + .exists() + ); + assert_eq!( + crate::guest::view::apply(&mut root, &view, None) + .unwrap_err() + .kind(), + crate::ErrorKind::Unsupported + ); + root.teardown().unwrap(); + assert!(mounts_below(&scene.path("root")).is_empty()); +} + +#[test] +#[ignore = "needs root and Linux 6.8 or later"] +fn view_generated_id_and_dns_are_read_only_and_the_root_unmounts_them() { + let Some((scene, mut root)) = view_scene() else { + return; + }; + root.mount_runtime(None).unwrap(); + let base = root.path().to_path_buf(); + let resolver = base.join("etc/resolv.conf"); + fs::remove_file(&resolver).unwrap(); + std::os::unix::fs::symlink("../run/image-resolver", &resolver).unwrap(); + let view = crate::activation::View { + hostname: b"vm-view".to_vec(), + machine_id: crate::activation::MachineIdMode::Generated, + }; + let selected = + crate::guest::view::apply(&mut root, &view, Some("fd00::53".parse().unwrap())).unwrap(); + let MachineId::Generated(source) = selected else { + panic!("no generated ID"); + }; + let id = fs::read(&source).unwrap(); + assert_eq!(id.len(), 33); + assert_eq!(fs::read(base.join("etc/machine-id")).unwrap(), id); + assert_eq!(fs::read(&resolver).unwrap(), b"nameserver fd00::53\n"); + for target in [base.join("etc/machine-id"), resolver.clone()] { + assert_eq!(mounted(&target).1, ["nodev", "nosuid", "ro"]); + assert_eq!( + fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o444 + ); + assert_eq!( + fs::write(&target, b"changed").unwrap_err().raw_os_error(), + Some(Errno::EROFS as i32) + ); + } + root.teardown().unwrap(); + assert!(mounts_below(&base).is_empty()); + assert_eq!( + fs::read(base.join("etc/machine-id")).unwrap(), + b"image-machine-id\n" + ); + assert!(fs::read(&resolver).unwrap().is_empty()); + assert_eq!(fs::read(source).unwrap(), id); + assert!(scene.path("root").is_dir()); +} + +#[test] +#[ignore = "needs root and Linux 6.8 or later"] +fn view_failure_after_the_machine_id_bind_leaves_it_owned_for_teardown() { + let Some((_scene, mut root)) = view_scene() else { + return; + }; + let base = root.path().to_path_buf(); + fs::remove_file(base.join("etc/resolv.conf")).unwrap(); + fs::create_dir(base.join("etc/resolv.conf")).unwrap(); + let view = crate::activation::View { + hostname: b"view-failure".to_vec(), + machine_id: crate::activation::MachineIdMode::Generated, + }; + let error = + crate::guest::view::apply(&mut root, &view, Some("10.0.0.1".parse().unwrap())).unwrap_err(); + assert_eq!(error.kind(), crate::ErrorKind::Io); + assert!(shown(&base.join("etc/machine-id")).is_some()); + assert!(root.is_latched()); + root.teardown().unwrap(); + assert!(mounts_below(&base).is_empty()); + assert_eq!( + fs::read(base.join("etc/machine-id")).unwrap(), + b"image-machine-id\n" + ); +} + +#[test] +#[ignore = "needs root and Linux 6.8 or later"] +fn view_binds_survive_the_actual_switch_teardown_refusal_and_drop() { + let Some((_scene, mut root)) = view_scene() else { + return; + }; + root.mount_runtime(None).unwrap(); + let view = crate::activation::View { + hostname: b"switched-view".to_vec(), + machine_id: crate::activation::MachineIdMode::Generated, + }; + let selected = + crate::guest::view::apply(&mut root, &view, Some("10.0.0.1".parse().unwrap())).unwrap(); + let MachineId::Generated(source) = selected else { + panic!("no generated ID"); + }; + let id = fs::read(source).unwrap(); + std::thread::scope(|scope| { + scope + .spawn(move || { + nix::sched::unshare(nix::sched::CloneFlags::CLONE_FS).unwrap(); + root.switch().unwrap(); + assert_eq!(fs::read("/etc/machine-id").unwrap(), id); + assert_eq!( + fs::read("/etc/resolv.conf").unwrap(), + b"nameserver 10.0.0.1\n" + ); + let mounts = mounts_below(Path::new("/")); + for target in ["/etc/machine-id", "/etc/resolv.conf"] { + assert!(mounts.contains(&PathBuf::from(target)), "{mounts:?}"); + } + assert!(root.teardown().unwrap_err().is_switched()); + assert_eq!(mounts_below(Path::new("/")), mounts); + assert_eq!(fs::read("/etc/machine-id").unwrap(), id); + assert_eq!( + fs::read("/etc/resolv.conf").unwrap(), + b"nameserver 10.0.0.1\n" + ); + }) + .join() + .unwrap(); + }); +} diff --git a/nvx-rs/src/guest/root/tests/view.rs b/nvx-rs/src/guest/root/tests/view.rs new file mode 100644 index 000000000..567814a80 --- /dev/null +++ b/nvx-rs/src/guest/root/tests/view.rs @@ -0,0 +1,180 @@ +use super::*; +use crate::guest::view::{self as vm_view, MACHINE_ID_FILE, RESOLVER_FILE}; +use crate::{ + ErrorKind, + activation::{MachineIdMode, View}, +}; + +/// Returns a root and its view files, with no view bound yet. +fn prepared() -> (Guest, Root, MachineId, PathBuf) { + let guest = Guest::new(); + let root = guest.assemble(&guest.assembly()).unwrap(); + fs::create_dir_all(root.path().join("etc")).unwrap(); + fs::write(root.path().join("etc/machine-id"), b"image\n").unwrap(); + let machine_id = MachineId::generate(root.runtime().join(MACHINE_ID_FILE)).unwrap(); + let resolver = root.runtime().join(RESOLVER_FILE); + vm_view::write_resolver_file(&resolver, "10.0.0.1".parse().unwrap()).unwrap(); + (guest, root, machine_id, resolver) +} + +#[test] +fn the_root_removes_its_view_binds_in_reverse_order_before_the_overlay() { + let (guest, mut root, machine_id, resolver) = prepared(); + root.begin_view().unwrap(); + root.bind_view(&machine_id, Some(&resolver)).unwrap(); + for target in ["etc/machine-id", "etc/resolv.conf"] { + assert!(guest.kernel.mount_at(&root.path().join(target)).is_some()); + } + root.teardown().unwrap(); + assert_eq!( + guest.kernel.unmounts(), + [ + "rootfs/etc/resolv.conf", + "rootfs/etc/machine-id", + "rootfs", + "layers/distro", + "scratch", + ] + .map(|path| guest.text(&format!("run/nvx/{path}"))) + ); +} + +#[test] +fn image_mode_without_dns_binds_nothing_and_a_second_view_is_refused() { + let (guest, mut root, _, _) = prepared(); + root.begin_view().unwrap(); + let calls = guest.kernel.calls().len(); + root.bind_view(&MachineId::Image, None).unwrap(); + assert_eq!(guest.kernel.calls().len(), calls); + assert_eq!( + root.begin_view().unwrap_err().kind(), + ErrorKind::Unsupported + ); + assert_eq!(guest.kernel.calls().len(), calls); + root.teardown().unwrap(); +} + +#[test] +fn invalid_hostname_bytes_are_refused_before_any_view_effect() { + let (guest, mut root, _, _) = prepared(); + for hostname in [Vec::new(), vec![b'h'; 65], b"bad\0name".to_vec()] { + let view = View { + hostname, + machine_id: MachineIdMode::Generated, + }; + let calls = guest.kernel.calls().len(); + assert_eq!( + vm_view::apply(&mut root, &view, None).unwrap_err().kind(), + ErrorKind::Unsupported + ); + assert!(root.view_binds.is_none()); + assert_eq!(guest.kernel.calls().len(), calls); + } + root.teardown().unwrap(); +} + +#[test] +fn a_later_bind_failure_stays_owned_and_its_teardown_removes_the_earlier_bind() { + let (guest, mut root, machine_id, resolver) = prepared(); + let target = root.path().join("etc/resolv.conf"); + guest + .kernel + .fail(Operation::MoveMount, &target, Errno::EPERM); + root.begin_view().unwrap(); + assert_eq!( + root.bind_view(&machine_id, Some(&resolver)) + .unwrap_err() + .kind(), + ErrorKind::Io + ); + assert!(root.is_latched()); + let machine = root.path().join("etc/machine-id"); + assert!(guest.kernel.mount_at(&machine).is_some()); + assert!(guest.kernel.mount_at(&target).is_none()); + root.teardown().unwrap(); + assert!(guest.kernel.mount_at(&machine).is_none()); +} + +#[test] +fn a_switched_root_leaves_its_view_binds_mounted() { + for teardown in [false, true] { + let (guest, mut root, machine_id, resolver) = prepared(); + root.begin_view().unwrap(); + root.bind_view(&machine_id, Some(&resolver)).unwrap(); + let targets = ["etc/machine-id", "etc/resolv.conf"].map(|target| root.path().join(target)); + root.switch().unwrap(); + if teardown { + assert!(root.teardown().unwrap_err().is_switched()); + } else { + drop(root); + } + assert!(guest.kernel.unmounts().is_empty()); + for target in targets { + assert!(guest.kernel.mount_at(&target).is_some()); + } + } +} + +#[test] +fn teardown_reports_each_view_bind_that_stays_mounted() { + let (guest, mut root, machine_id, resolver) = prepared(); + root.begin_view().unwrap(); + root.bind_view(&machine_id, Some(&resolver)).unwrap(); + for target in ["etc/machine-id", "etc/resolv.conf"] { + guest + .kernel + .fail(Operation::Unmount, root.path().join(target), Errno::EBUSY); + } + let error = root.teardown().unwrap_err(); + let paths: Vec<_> = error + .failures() + .iter() + .map(|failure| failure.path().to_path_buf()) + .collect(); + assert_eq!( + paths, + ["etc/resolv.conf", "etc/machine-id"] + .map(|target| guest.path("run/nvx/rootfs").join(target)) + ); +} + +#[test] +fn a_view_bind_that_moves_and_cannot_detach_stays_owned_until_teardown() { + for persistent in [false, true] { + let (guest, mut root, _, resolver) = prepared(); + let target = root.path().join("etc/resolv.conf"); + let parent = target.parent().unwrap().to_path_buf(); + let moved_parent = root.path().join("moved"); + let moved = moved_parent.join("resolv.conf"); + guest.kernel.fail(Operation::Detach, &moved, Errno::EBUSY); + guest.kernel.on_move(move |path| { + if path == target { + fs::rename(&parent, &moved_parent).unwrap(); + } + }); + root.begin_view().unwrap(); + let error = root + .bind_view(&MachineId::Image, Some(&resolver)) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io); + assert!(root.view_stray.is_some()); + assert!(guest.kernel.mount_at(&moved).is_some()); + if !persistent { + guest.kernel.restore(Operation::Detach, &moved); + } + let torn = root.teardown(); + if persistent { + let error = torn.unwrap_err(); + assert!( + error.failures().iter().any(|failure| { + failure.path() == guest.path("run/nvx/rootfs/etc/resolv.conf") + && failure.error().raw_os_error() == Some(Errno::EBUSY as i32) + }), + "{error:?}" + ); + } else { + torn.unwrap(); + } + assert_eq!(guest.kernel.mount_at(&moved).is_some(), persistent); + } +} diff --git a/nvx-rs/src/guest/sys/entry.rs b/nvx-rs/src/guest/sys/entry.rs index 0bbd51bff..a73409635 100644 --- a/nvx-rs/src/guest/sys/entry.rs +++ b/nvx-rs/src/guest/sys/entry.rs @@ -469,8 +469,8 @@ fn make_mounts_private() -> Result<(), Errno> { ) } -/// Sets the hostname of the child's new UTS namespace. -fn set_hostname(hostname: &[u8]) -> Result<(), Errno> { +/// Sets the hostname of the current UTS namespace. +pub(crate) fn set_hostname(hostname: &[u8]) -> Result<(), Errno> { // SAFETY: the kernel reads `hostname.len()` bytes from the slice. unit(unsafe { libc::syscall(libc::SYS_sethostname, hostname.as_ptr(), hostname.len()) }) } diff --git a/nvx-rs/src/guest/sys/mod.rs b/nvx-rs/src/guest/sys/mod.rs index be503e4f9..328797ec7 100644 --- a/nvx-rs/src/guest/sys/mod.rs +++ b/nvx-rs/src/guest/sys/mod.rs @@ -91,7 +91,7 @@ pub(crate) use clock::{monotonic_ns, syscall_monotonic_nanoseconds}; pub(crate) use discipline::{ClockAdjustment, ClockDiscipline, FrequencyHold, adjust_clock}; pub(crate) use entry::{ DEV, DEV_PTS, DEV_SHM, EntryChild, FileSystem, PROC, RUN, SYS, create_devices, file_size, - mount_detached, restrict_tree, + mount_detached, restrict_tree, set_hostname, }; #[cfg(target_arch = "x86_64")] pub(crate) use fd::close_inherited_descriptors; diff --git a/nvx-rs/src/guest/view.rs b/nvx-rs/src/guest/view.rs new file mode 100644 index 000000000..f1ea51076 --- /dev/null +++ b/nvx-rs/src/guest/view.rs @@ -0,0 +1,149 @@ +//! The VM-wide hostname, machine ID, and resolver file of an assembled root. +//! +//! This is unstable internal reference code of the guest's space, hidden from documentation; +//! it becomes crate-private once nothing outside NVX uses it. The root owns the view's binds: +//! its teardown removes them before its switch, and nothing unmounts them afterwards. + +use std::fs; +use std::io; +use std::net::IpAddr; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; + +#[cfg(doc)] +use super::entry::Entry; +use super::root::Root; +use super::sys::{self, Mode, OpenFlags}; +use crate::activation::{self, MachineIdMode, View}; +use crate::protocol::cmdline::write_resolver; +use crate::{Error, Result}; + +/// The hostname used unless a caller names another. +pub const DEFAULT_HOSTNAME: &str = "nvx-sandbox"; +/// The longest guest hostname, in bytes: Linux's `HOST_NAME_MAX`. +pub const MAX_HOSTNAME_BYTES: usize = 64; +/// The generated machine ID's file in the root's runtime directory. +pub const MACHINE_ID_FILE: &str = "workload-machine-id"; +/// The resolver file in the root's runtime directory. +pub const RESOLVER_FILE: &str = "resolv.conf"; +/// The mode of a generated machine ID's file. +const MACHINE_ID_MODE: u32 = 0o444; +/// The mode of the resolver file, whatever the umask. +const RESOLVER_MODE: u32 = 0o444; +/// The generated machine ID's target in the root. +pub(crate) const MACHINE_ID_TARGET: &str = "/etc/machine-id"; +/// The resolver file's target in the root. +pub(crate) const RESOLVER_TARGET: &str = "/etc/resolv.conf"; + +/// The machine ID visible at `/etc/machine-id`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] +pub enum MachineId { + /// The image's own `/etc/machine-id`, left untouched. + #[default] + Image, + /// The ID in this regular file, bound read-only over `/etc/machine-id`, as + /// [`Entry::bind_runtime`] also does. Rewriting the file in place, such as after a restore, + /// reaches every execution that sees the bind. + Generated(PathBuf), +} + +impl MachineId { + /// Generates a random version 4 UUID and writes it to a new file at `path`, with mode 0444 + /// whatever the umask: 32 lowercase hexadecimal digits and a newline. + /// + /// An existing file or symbolic link fails with `AlreadyExists` and is left alone. + pub fn generate(path: impl Into) -> io::Result { + let path = path.into(); + let mut id = [0u8; 16]; + getrandom::fill(&mut id).map_err(|error| { + error + .raw_os_error() + .map_or_else(|| io::Error::other(error), io::Error::from_raw_os_error) + })?; + let mode = Mode::from_bits_retain(MACHINE_ID_MODE); + let file = sys::open( + &sys::path_c_string(&path)?, + OpenFlags::O_WRONLY + | OpenFlags::O_CREAT + | OpenFlags::O_EXCL + | OpenFlags::O_NOFOLLOW + | OpenFlags::O_CLOEXEC, + mode, + )?; + file.set_mode(mode)?; + file.write_all(&machine_id_text(id))?; + file.close()?; + Ok(Self::Generated(path)) + } +} + +/// Sets the VM's hostname and applies its machine-ID mode and optional single DNS server. +/// +/// Takes the actual hostname bytes of `View`, including those decoded by +/// [`protocol::view`](crate::protocol::view), not the legacy raw kernel token. Image mode leaves +/// the image's machine ID alone; generated mode writes a new ID in the runtime directory and +/// binds it read-only over the image's. DNS, when supplied, writes and binds the existing +/// single-server resolver format the same way. +/// +/// The root owns every bind, including those made before a later failure. Its teardown removes +/// them before the switch; after the switch nothing unmounts them. The call needs the privilege +/// to set the hostname and mount, and refuses an invalid hostname, a switched or failed root, +/// or a root whose view has already been attempted. System failures have kind `Io`; refusals +/// have kind `Unsupported`. An error does not restore the previous hostname or remove files. +pub fn apply(root: &mut Root, view: &View, dns: Option) -> Result { + if !activation::is_hostname(&view.hostname) { + return Err(Error::unsupported("the view's hostname is not valid")); + } + root.begin_view()?; + sys::set_hostname(&view.hostname).map_err(|error| { + Error::io(format!("failed to set the VM hostname: {error}")).with_source(error) + })?; + let machine_id = match view.machine_id { + MachineIdMode::Image => MachineId::Image, + MachineIdMode::Generated => MachineId::generate(root.runtime().join(MACHINE_ID_FILE)) + .map_err(|error| { + Error::io(format!("failed to generate the VM machine ID: {error}")) + .with_source(error) + })?, + }; + let resolver = dns + .map(|server| { + let path = root.runtime().join(RESOLVER_FILE); + write_resolver_file(&path, server).map(|()| path) + }) + .transpose() + .map_err(|error| { + Error::io(format!("failed to write the VM resolver file: {error}")).with_source(error) + })?; + root.bind_view(&machine_id, resolver.as_deref())?; + Ok(machine_id) +} + +/// Returns a random ID's version 4 UUID text: 32 lowercase hexadecimal digits and a newline. +fn machine_id_text(mut id: [u8; 16]) -> [u8; 33] { + /// The lowercase hexadecimal digits. + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + id[6] = (id[6] & 0x0f) | 0x40; + id[8] = (id[8] & 0x3f) | 0x80; + let mut text = [b'\n'; 33]; + for (index, byte) in id.iter().enumerate() { + text[2 * index] = DIGITS[usize::from(byte >> 4)]; + text[2 * index + 1] = DIGITS[usize::from(byte & 0x0f)]; + } + text +} + +/// Writes one DNS server to a new resolver file, with mode 0444 whatever the umask. +pub(crate) fn write_resolver_file(path: &Path, server: IpAddr) -> io::Result<()> { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(RESOLVER_MODE) + .open(path)?; + file.set_permissions(fs::Permissions::from_mode(RESOLVER_MODE))?; + write_resolver(&mut file, server) +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/guest/view/tests.rs b/nvx-rs/src/guest/view/tests.rs new file mode 100644 index 000000000..7b9074994 --- /dev/null +++ b/nvx-rs/src/guest/view/tests.rs @@ -0,0 +1,101 @@ +use super::*; +use std::os::unix::fs::PermissionsExt; + +#[test] +fn machine_ids_are_version_4_uuids_in_lowercase_hexadecimal() { + assert_eq!( + &machine_id_text([0; 16]), + b"00000000000040008000000000000000\n" + ); + assert_eq!( + &machine_id_text([0xff; 16]), + b"ffffffffffff4fffbfffffffffffffff\n" + ); + assert_eq!( + &machine_id_text([ + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, + 0xcd, 0xef + ]), + b"0123456789ab4def8123456789abcdef\n" + ); +} + +#[test] +fn a_machine_id_is_generated_once_per_file() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("machine-id"); + let mode = MachineId::generate(&path).unwrap(); + assert_eq!(mode, MachineId::Generated(path.clone())); + let text = std::fs::read(&path).unwrap(); + assert_eq!(text.len(), 33); + assert_eq!(text[32], b'\n'); + assert!( + text[..32] + .iter() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) + ); + assert_eq!(text[12], b'4'); + assert!(matches!(text[16], b'8' | b'9' | b'a' | b'b')); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o444 + ); + let error = MachineId::generate(&path).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read(&path).unwrap(), text); + let target = directory.path().join("target"); + let link = directory.path().join("link"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert_eq!( + MachineId::generate(&link).unwrap_err().kind(), + io::ErrorKind::AlreadyExists + ); + assert!(!target.exists()); + let other = directory.path().join("other"); + MachineId::generate(&other).unwrap(); + assert_ne!(std::fs::read(&other).unwrap(), text); +} + +#[test] +fn a_generated_machine_id_has_mode_0444_whatever_the_umask() { + if crate::guest::sys::tests::isolate_process_test( + "NVX_ENTRY_UMASK_TEST_CHILD", + "guest::view::tests::a_generated_machine_id_has_mode_0444_whatever_the_umask", + ) { + return; + } + nix::sys::stat::umask(sys::Mode::from_bits_truncate(0o077)); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("machine-id"); + MachineId::generate(&path).unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o7777, + 0o444 + ); +} + +#[test] +fn the_resolver_keeps_the_single_server_format_and_refuses_existing_files_and_links() { + let directory = tempfile::tempdir().unwrap(); + for (name, server, expected) in [ + ("ipv4", "10.0.0.1", "nameserver 10.0.0.1\n"), + ("ipv6", "fd00::53", "nameserver fd00::53\n"), + ] { + let path = directory.path().join(name); + write_resolver_file(&path, server.parse().unwrap()).unwrap(); + assert_eq!(fs::read(&path).unwrap(), expected.as_bytes()); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o444 + ); + let error = write_resolver_file(&path, "10.0.0.2".parse().unwrap()).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + assert_eq!(fs::read(&path).unwrap(), expected.as_bytes()); + } + let link = directory.path().join("link"); + let target = directory.path().join("missing"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let error = write_resolver_file(&link, "10.0.0.1".parse().unwrap()).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + assert!(!target.exists()); +} From 4fde793382c6e2f25a23871e7fed9a4487ec2379 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 02:00:35 -0800 Subject: [PATCH 2/6] guest: align view tests with root switch behavior Check that switch teardown and drop issue no unmount or detach through the existing kernel seam, while native tests check bind availability after the actual switch. Cover refusal on switched or latched roots, keep resolver file cases with their new owner, and document the view bind failure latch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f407efdc-4592-4fb9-8abc-095402b6df7b Copilot-Session: a773baad-9362-4d2f-be37-d4f9eda25cee --- .../src/guest/clone/activation/apply/tests.rs | 37 -------------- nvx-rs/src/guest/root.rs | 8 +-- nvx-rs/src/guest/root/tests/view.rs | 35 +++++++++++-- nvx-rs/src/guest/view/tests.rs | 50 ++++++++++++------- 4 files changed, 66 insertions(+), 64 deletions(-) diff --git a/nvx-rs/src/guest/clone/activation/apply/tests.rs b/nvx-rs/src/guest/clone/activation/apply/tests.rs index 53fed6d64..70c5789d5 100644 --- a/nvx-rs/src/guest/clone/activation/apply/tests.rs +++ b/nvx-rs/src/guest/clone/activation/apply/tests.rs @@ -20,7 +20,6 @@ use std::error::Error as _; use std::fs; use std::io; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use std::os::unix::fs::{PermissionsExt, symlink}; use std::rc::Rc; use std::time::Instant; @@ -1420,42 +1419,6 @@ fn a_network_s_dns_server_is_written_to_a_resolver_file_that_every_execution_see } } -#[test] -fn a_resolver_file_names_its_dns_server_once_and_every_execution_may_read_it() { - // The file names the server in its canonical text, as NVX's init writes the initramfs's, - // with mode 0444, whatever the umask. - let directory = tempfile::tempdir().unwrap(); - let ipv6 = IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0xa00, 1)); - let cases = [ - ("v4", DNS, &b"nameserver 10.0.0.1\n"[..]), - ("v6", ipv6, &b"nameserver fd00::a00:1\n"[..]), - ]; - for (name, server, text) in cases { - let path = directory.path().join(name); - sys::tests::with_thread_umask(0o077, || write_resolver_file(&path, server)).unwrap(); - assert_eq!(fs::read(&path).unwrap(), text); - let mode = fs::metadata(&path).unwrap().permissions().mode(); - assert_eq!(mode & 0o7777, 0o444, "{mode:o}"); - } - - // A file there already fails the write, and is left alone, and so is the file that a - // symbolic link there names, or the path where a dangling one leads. - let written = directory.path().join("v4"); - let error = write_resolver_file(&written, ipv6).unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); - let link = directory.path().join("link"); - symlink(&written, &link).unwrap(); - let error = write_resolver_file(&link, ipv6).unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); - assert_eq!(fs::read(&written).unwrap(), b"nameserver 10.0.0.1\n"); - let absent = directory.path().join("absent"); - let dangling = directory.path().join("dangling"); - symlink(&absent, &dangling).unwrap(); - let error = write_resolver_file(&dangling, DNS).unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); - assert!(!absent.exists()); -} - #[test] fn an_export_or_a_network_that_fails_fails_the_activation_once_the_root_is_torn_down() { // The export's mount fails with the export's class, and the resolver file's write and bind, diff --git a/nvx-rs/src/guest/root.rs b/nvx-rs/src/guest/root.rs index 2d047a2c6..9c3573f20 100644 --- a/nvx-rs/src/guest/root.rs +++ b/nvx-rs/src/guest/root.rs @@ -744,8 +744,8 @@ pub struct Root { /// What each of the mapper's mappings mounts, in the mapper's order, then what the one that /// a rename moved and that the mapper failed to detach mounts, if any. mapped: Vec, - /// Whether a share, the export, or a mapping failed, after which the root refuses every later - /// one. + /// Whether a share, the export, a mapping, or a view bind failed, after which the root refuses + /// later shares, exports, mappings, and view applications. latched: bool, /// Whether the root's mount has moved onto `/`, after which the root refuses another switch /// and its teardown, and unmounts nothing. @@ -818,8 +818,8 @@ impl Root { Ok(()) } - /// Returns whether a live share, the host export, or a mapping failed, after which the root - /// refuses every later one, so that its caller starts no workload in it. + /// Returns whether a live share, the host export, a mapping, or a view bind failed, after which + /// the root refuses later shares, exports, mappings, and views. pub fn is_latched(&self) -> bool { self.latched } diff --git a/nvx-rs/src/guest/root/tests/view.rs b/nvx-rs/src/guest/root/tests/view.rs index 567814a80..e8150ba64 100644 --- a/nvx-rs/src/guest/root/tests/view.rs +++ b/nvx-rs/src/guest/root/tests/view.rs @@ -96,22 +96,49 @@ fn a_later_bind_failure_stays_owned_and_its_teardown_removes_the_earlier_bind() } #[test] -fn a_switched_root_leaves_its_view_binds_mounted() { +fn a_switched_root_never_unmounts_or_detaches_its_view_binds() { for teardown in [false, true] { let (guest, mut root, machine_id, resolver) = prepared(); root.begin_view().unwrap(); root.bind_view(&machine_id, Some(&resolver)).unwrap(); - let targets = ["etc/machine-id", "etc/resolv.conf"].map(|target| root.path().join(target)); + for target in ["etc/machine-id", "etc/resolv.conf"] { + assert!(guest.kernel.mount_at(&root.path().join(target)).is_some()); + } root.switch().unwrap(); + let switched = guest.kernel.calls(); if teardown { assert!(root.teardown().unwrap_err().is_switched()); } else { drop(root); } assert!(guest.kernel.unmounts().is_empty()); - for target in targets { - assert!(guest.kernel.mount_at(&target).is_some()); + assert_eq!(guest.kernel.calls(), switched); + } +} + +#[test] +fn switched_or_failed_roots_refuse_a_view_before_opening_its_root() { + for switched in [false, true] { + let guest = Guest::new(); + let mut root = guest.assemble(&guest.assembly()).unwrap(); + if switched { + root.switch().unwrap(); + } else { + guest.kernel.fail( + Operation::Mount, + guest.path("run/nvx/hostfs/root"), + Errno::ENODEV, + ); + root.mount_export("microvm", Access::ReadOnly).unwrap_err(); + assert!(root.is_latched()); } + let calls = guest.kernel.calls(); + assert_eq!( + root.begin_view().unwrap_err().kind(), + ErrorKind::Unsupported + ); + assert!(root.view_binds.is_none()); + assert_eq!(guest.kernel.calls(), calls); } } diff --git a/nvx-rs/src/guest/view/tests.rs b/nvx-rs/src/guest/view/tests.rs index 7b9074994..a9003c025 100644 --- a/nvx-rs/src/guest/view/tests.rs +++ b/nvx-rs/src/guest/view/tests.rs @@ -1,5 +1,6 @@ use super::*; -use std::os::unix::fs::PermissionsExt; +use std::net::{Ipv4Addr, Ipv6Addr}; +use std::os::unix::fs::{PermissionsExt, symlink}; #[test] fn machine_ids_are_version_4_uuids_in_lowercase_hexadecimal() { @@ -75,27 +76,38 @@ fn a_generated_machine_id_has_mode_0444_whatever_the_umask() { } #[test] -fn the_resolver_keeps_the_single_server_format_and_refuses_existing_files_and_links() { +fn a_resolver_file_names_its_dns_server_once_and_every_execution_may_read_it() { + // The file names the server in its canonical text, as NVX's init writes the initramfs's, + // with mode 0444, whatever the umask. let directory = tempfile::tempdir().unwrap(); - for (name, server, expected) in [ - ("ipv4", "10.0.0.1", "nameserver 10.0.0.1\n"), - ("ipv6", "fd00::53", "nameserver fd00::53\n"), - ] { + let dns = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let ipv6 = IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0xa00, 1)); + let cases = [ + ("v4", dns, &b"nameserver 10.0.0.1\n"[..]), + ("v6", ipv6, &b"nameserver fd00::a00:1\n"[..]), + ]; + for (name, server, text) in cases { let path = directory.path().join(name); - write_resolver_file(&path, server.parse().unwrap()).unwrap(); - assert_eq!(fs::read(&path).unwrap(), expected.as_bytes()); - assert_eq!( - fs::metadata(&path).unwrap().permissions().mode() & 0o777, - 0o444 - ); - let error = write_resolver_file(&path, "10.0.0.2".parse().unwrap()).unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); - assert_eq!(fs::read(&path).unwrap(), expected.as_bytes()); + sys::tests::with_thread_umask(0o077, || write_resolver_file(&path, server)).unwrap(); + assert_eq!(fs::read(&path).unwrap(), text); + let mode = fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o7777, 0o444, "{mode:o}"); } + + // A file there already fails the write, and is left alone, and so is the file that a + // symbolic link there names, or the path where a dangling one leads. + let written = directory.path().join("v4"); + let error = write_resolver_file(&written, ipv6).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); let link = directory.path().join("link"); - let target = directory.path().join("missing"); - std::os::unix::fs::symlink(&target, &link).unwrap(); - let error = write_resolver_file(&link, "10.0.0.1".parse().unwrap()).unwrap_err(); + symlink(&written, &link).unwrap(); + let error = write_resolver_file(&link, ipv6).unwrap_err(); assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); - assert!(!target.exists()); + assert_eq!(fs::read(&written).unwrap(), b"nameserver 10.0.0.1\n"); + let absent = directory.path().join("absent"); + let dangling = directory.path().join("dangling"); + symlink(&absent, &dangling).unwrap(); + let error = write_resolver_file(&dangling, dns).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + assert!(!absent.exists()); } From bd46ad2982e778b24edb45751e7609e5e381e4b7 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 03:56:46 -0800 Subject: [PATCH 3/6] guest: own runtime file bindings in the root Move the shared file-binding operations and their helper tests to the root. Root-wide runtime and view setup no longer depend on execution entry; legacy entry bindings reuse the same implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f407efdc-4592-4fb9-8abc-095402b6df7b Copilot-Session: a773baad-9362-4d2f-be37-d4f9eda25cee --- nvx-rs/src/guest/entry.rs | 121 +------------------------- nvx-rs/src/guest/entry/tests.rs | 50 ----------- nvx-rs/src/guest/root.rs | 129 ++++++++++++++++++++++++++-- nvx-rs/src/guest/root/tests/view.rs | 55 ++++++++++++ 4 files changed, 180 insertions(+), 175 deletions(-) diff --git a/nvx-rs/src/guest/entry.rs b/nvx-rs/src/guest/entry.rs index 6bec4e34a..b63cb58db 100644 --- a/nvx-rs/src/guest/entry.rs +++ b/nvx-rs/src/guest/entry.rs @@ -126,12 +126,11 @@ use std::io::{self, Read}; use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; -use super::mappings::mapper::{AttachFailure, bind_retained_with}; use super::mappings::{ - self, Access, Kernel, Kind, Mapped, Mapping, MappingError, SourceRoot, Step, SystemKernel, - TargetRoot, TeardownError, + self, Kernel, Kind, Mapped, MappingError, SystemKernel, TargetRoot, TeardownError, }; use super::parse::parse_decimal; +use super::root::{TIME_STATUS_TARGET, bind_file_retained, bind_runtime_file}; #[cfg(doc)] use super::spawn::{Command, LaunchStage}; use super::sys::{self, EntryChild, Errno, FileDescriptor, Mode, OpenFlags}; @@ -151,10 +150,6 @@ const PASSWD: &std::ffi::CStr = c"etc/passwd"; const GROUP: &std::ffi::CStr = c"etc/group"; /// The ID that `setresuid` and `setresgid` read as "unchanged", which no identity may hold. const UNCHANGED_ID: u32 = u32::MAX; -/// Where [`Entry::bind_runtime`] binds the time status in the root, as a layered root's -/// [`mount_runtime`](super::root::Root::mount_runtime) does. -pub(crate) const TIME_STATUS_TARGET: &str = "/run/nvx/time"; - /// The names of the Linux capabilities, by number. const CAPABILITY_NAMES: [&str; 41] = [ "CAP_CHOWN", @@ -798,118 +793,6 @@ fn bind_file( bind_file_retained(kernel, root, path, source, target).map_err(|failure| failure.error) } -/// Binds a runtime file as [`bind_file`] does, retaining an attachment that could not be detached. -pub(crate) fn bind_file_retained( - kernel: &mut dyn Kernel, - root: &TargetRoot, - path: &Path, - source: &Path, - target: &str, -) -> Result { - match bind_runtime_file_retained(kernel, root, source, target, Kind::File) { - Err(failure) - if matches!(&failure.error, MappingError::Link { path: link } - if link == Path::new(target)) => - { - remove_link(path, target).map_err(|_| failure)?; - bind_runtime_file_retained(kernel, root, source, target, Kind::File) - } - bound => bound, - } -} - -/// Binds the file or directory at `path`, which must be of `kind`, read-only at `target` beneath -/// `root`, with `kernel`, through the walk and attach of [`mappings::bind`] from a source root at -/// `path`'s parent directory, and returns the bind. -pub(crate) fn bind_runtime_file( - kernel: &mut dyn Kernel, - root: &TargetRoot, - path: &Path, - target: &str, - kind: Kind, -) -> Result { - let (source, mapping) = runtime_mapping(path, target, kind)?; - mappings::bind_with(kernel, root, &source, &mapping) -} - -/// Binds as [`bind_runtime_file`] does, retaining a failed bind that its caller must detach. -pub(crate) fn bind_runtime_file_retained( - kernel: &mut dyn Kernel, - root: &TargetRoot, - path: &Path, - target: &str, - kind: Kind, -) -> Result { - let (source, mapping) = runtime_mapping(path, target, kind)?; - bind_retained_with(kernel, root, &source, &mapping) -} - -/// Prepares the source root and read-only mapping for a runtime bind. -fn runtime_mapping( - path: &Path, - target: &str, - kind: Kind, -) -> Result<(SourceRoot, Mapping), MappingError> { - let source_error = |error: i32| MappingError::System { - step: Step::Source, - error, - }; - let Some((parent, name)) = runtime_source(path) else { - return Err(source_error(Errno::EINVAL as i32)); - }; - let source = SourceRoot::open(parent) - .map_err(|error| source_error(error.raw_os_error().unwrap_or(Errno::EIO as i32)))?; - let mapping = Mapping::new(name, target, Access::ReadOnly) - .map_err(|_| source_error(Errno::EINVAL as i32))? - .with_kind(kind); - Ok((source, mapping)) -} - -/// Returns the directory that holds the runtime file at `path`, the current directory for a -/// relative path of one component, and the file's name in it, unless `path` names no file. -fn runtime_source(path: &Path) -> Option<(&Path, &std::ffi::OsStr)> { - let name = path.file_name()?; - let parent = path.parent()?; - if parent.as_os_str().is_empty() { - Some((Path::new("."), name)) - } else { - Some((parent, name)) - } -} - -/// Removes the symbolic link at `target`, a file of `/etc` such as `/etc/machine-id`, beneath -/// `root`, reached through no other link and on the root's own mount. -fn remove_link(root: &Path, target: &str) -> Result<(), Errno> { - let name = target.strip_prefix("/etc/").ok_or(Errno::EINVAL)?; - let name = sys::c_string(name.as_bytes())?; - let root = sys::path_c_string(root)?; - let directory = sys::open( - &root, - OpenFlags::O_PATH | OpenFlags::O_DIRECTORY | OpenFlags::O_NOFOLLOW | OpenFlags::O_CLOEXEC, - Mode::empty(), - )?; - let etc = sys::open_beneath( - &directory, - c"etc", - OpenFlags::O_PATH | OpenFlags::O_DIRECTORY, - Mode::empty(), - false, - )?; - let status = nix::sys::stat::fstatat( - etc.borrowed(), - name.as_c_str(), - nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW, - )?; - if status.st_mode & nix::libc::S_IFMT != nix::libc::S_IFLNK { - return Err(Errno::EEXIST); - } - nix::unistd::unlinkat( - etc.borrowed(), - name.as_c_str(), - nix::unistd::UnlinkatFlags::NoRemoveDir, - ) -} - /// One execution that an [`Entry`] prepared, which [`Command::enter`] takes. #[derive(Debug)] pub struct Prepared { diff --git a/nvx-rs/src/guest/entry/tests.rs b/nvx-rs/src/guest/entry/tests.rs index 5f3e002ae..58b2e7804 100644 --- a/nvx-rs/src/guest/entry/tests.rs +++ b/nvx-rs/src/guest/entry/tests.rs @@ -727,56 +727,6 @@ fn a_runtime_bind_that_stays_is_reported() { assert!(fixture.kernel.mount_at(&points[0]).is_none()); } -#[test] -fn runtime_sources_are_the_directories_beside_their_files() { - use std::ffi::OsStr; - - // A relative path of one component names a file of the current directory. - assert_eq!( - runtime_source(Path::new("machine-id")), - Some((Path::new("."), OsStr::new("machine-id"))) - ); - assert_eq!( - runtime_source(Path::new("/run/nvx/workload-machine-id")), - Some((Path::new("/run/nvx"), OsStr::new("workload-machine-id"))) - ); - assert_eq!( - runtime_source(Path::new("state/time")), - Some((Path::new("state"), OsStr::new("time"))) - ); - for path in ["", "/", "..", "time/.."] { - assert_eq!(runtime_source(Path::new(path)), None, "{path}"); - } -} - -#[test] -fn only_a_link_at_a_runtime_file_of_etc_is_removed() { - for target in [MACHINE_ID_TARGET, RESOLVER_TARGET] { - let root = root_with(None, None); - let link = root.path().join(target.trim_start_matches('/')); - let outside = tempfile::NamedTempFile::new().unwrap(); - std::os::unix::fs::symlink(outside.path(), &link).unwrap(); - remove_link(root.path(), target).unwrap(); - assert!(std::fs::symlink_metadata(&link).is_err()); - assert!(outside.path().exists()); - assert_eq!(remove_link(root.path(), target), Err(Errno::ENOENT)); - std::fs::write(&link, b"image\n").unwrap(); - assert_eq!(remove_link(root.path(), target), Err(Errno::EEXIST)); - assert_eq!(std::fs::read(&link).unwrap(), b"image\n"); - // A link on the way to it is not followed. - let linked = tempfile::tempdir().unwrap(); - std::os::unix::fs::symlink(root.path().join("etc"), linked.path().join("etc")).unwrap(); - assert_eq!(remove_link(linked.path(), target), Err(Errno::ELOOP)); - assert_eq!(std::fs::read(&link).unwrap(), b"image\n"); - } - // Only a file of `/etc` is a runtime file there. - let root = root_with(None, None); - assert_eq!( - remove_link(root.path(), TIME_STATUS_TARGET), - Err(Errno::EINVAL) - ); -} - #[test] fn device_sets_select_their_nodes() { let entry = entry(Identity::new(1, 1)); diff --git a/nvx-rs/src/guest/root.rs b/nvx-rs/src/guest/root.rs index 9c3573f20..d769d3f0f 100644 --- a/nvx-rs/src/guest/root.rs +++ b/nvx-rs/src/guest/root.rs @@ -189,9 +189,12 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use self::kernel::{Kernel, SystemKernel}; -use super::entry::{self, TIME_STATUS_TARGET}; use super::gpt::DeviceError; -use super::mappings::{self, Kind, Mapped, Mapper, MappingError, TargetRoot, below_its_root}; +use super::mappings::mapper::{AttachFailure, bind_retained_with}; +use super::mappings::{ + self, Access, Kind, Mapped, Mapper, Mapping, MappingError, SourceRoot, TargetRoot, + below_its_root, +}; use super::sys::{self, Errno, FileDescriptor, FileSystem, Mode, MountFlags, OpenFlags}; use super::view::{MACHINE_ID_TARGET, MachineId, RESOLVER_TARGET}; use crate::gpt::{Expected, GptError, Partition}; @@ -203,6 +206,9 @@ pub use self::bootstrap::{Bootstrap, BootstrapStep, Resources, bootstrap}; pub(crate) use self::kernel::Paths; pub use self::share::{ShareChild, ShareKind, ShareRefusal}; +/// Where the runtime binds its time status read-only in the root. +pub(crate) const TIME_STATUS_TARGET: &str = "/run/nvx/time"; + /// The directory of the layers' mount points, below the runtime directory. const LAYERS_DIRECTORY: &str = "layers"; /// The EROFS image's mount point, below the runtime directory. @@ -782,7 +788,7 @@ impl Root { Ok(()) } - /// Binds the view's fixed files through the existing entry seam and retains every attachment. + /// Binds the view's fixed files and retains every attachment. pub(crate) fn bind_view( &mut self, machine_id: &MachineId, @@ -801,8 +807,7 @@ impl Root { let Some(source) = source else { continue; }; - match entry::bind_file_retained(self.kernel.as_mut(), root, &self.path, source, target) - { + match bind_file_retained(self.kernel.as_mut(), root, &self.path, source, target) { Ok(bound) => binds.push(bound), Err(failure) => { self.latched = true; @@ -1007,7 +1012,7 @@ impl Root { target .add_mount_with(self.kernel.as_mut(), &run) .map_err(|error| Error::MountPoint { path: run, error })?; - let bound = entry::bind_runtime_file_retained( + let bound = bind_runtime_file_retained( self.kernel.as_mut(), &target, source, @@ -1550,6 +1555,118 @@ impl fmt::Debug for Root { } } +/// Binds a view file read-only, retaining an attachment that could not be detached. +pub(crate) fn bind_file_retained( + kernel: &mut dyn mappings::Kernel, + root: &TargetRoot, + path: &Path, + source: &Path, + target: &str, +) -> Result { + match bind_runtime_file_retained(kernel, root, source, target, Kind::File) { + Err(failure) + if matches!(&failure.error, MappingError::Link { path: link } + if link == Path::new(target)) => + { + remove_link(path, target).map_err(|_| failure)?; + bind_runtime_file_retained(kernel, root, source, target, Kind::File) + } + bound => bound, + } +} + +/// Binds the file or directory at `path`, which must be of `kind`, read-only at `target` beneath +/// `root`, with `kernel`, through the walk and attach of [`mappings::bind`] from a source root at +/// `path`'s parent directory, and returns the bind. +pub(crate) fn bind_runtime_file( + kernel: &mut dyn mappings::Kernel, + root: &TargetRoot, + path: &Path, + target: &str, + kind: Kind, +) -> Result { + let (source, mapping) = runtime_mapping(path, target, kind)?; + mappings::bind_with(kernel, root, &source, &mapping) +} + +/// Binds as [`bind_runtime_file`] does, retaining a failed bind that its caller must detach. +pub(crate) fn bind_runtime_file_retained( + kernel: &mut dyn mappings::Kernel, + root: &TargetRoot, + path: &Path, + target: &str, + kind: Kind, +) -> Result { + let (source, mapping) = runtime_mapping(path, target, kind)?; + bind_retained_with(kernel, root, &source, &mapping) +} + +/// Prepares the source root and read-only mapping for a runtime bind. +fn runtime_mapping( + path: &Path, + target: &str, + kind: Kind, +) -> Result<(SourceRoot, Mapping), MappingError> { + let source_error = |error: i32| MappingError::System { + step: mappings::Step::Source, + error, + }; + let Some((parent, name)) = runtime_source(path) else { + return Err(source_error(Errno::EINVAL as i32)); + }; + let source = SourceRoot::open(parent) + .map_err(|error| source_error(error.raw_os_error().unwrap_or(Errno::EIO as i32)))?; + let mapping = Mapping::new(name, target, Access::ReadOnly) + .map_err(|_| source_error(Errno::EINVAL as i32))? + .with_kind(kind); + Ok((source, mapping)) +} + +/// Returns the directory that holds the runtime file at `path`, the current directory for a +/// relative path of one component, and the file's name in it, unless `path` names no file. +fn runtime_source(path: &Path) -> Option<(&Path, &std::ffi::OsStr)> { + let name = path.file_name()?; + let parent = path.parent()?; + if parent.as_os_str().is_empty() { + Some((Path::new("."), name)) + } else { + Some((parent, name)) + } +} + +/// Removes the symbolic link at `target`, a file of `/etc` such as `/etc/machine-id`, beneath +/// `root`, reached through no other link and on the root's own mount. +fn remove_link(root: &Path, target: &str) -> Result<(), Errno> { + let name = target.strip_prefix("/etc/").ok_or(Errno::EINVAL)?; + let name = sys::c_string(name.as_bytes())?; + let root = sys::path_c_string(root)?; + let directory = sys::open( + &root, + OpenFlags::O_PATH | OpenFlags::O_DIRECTORY | OpenFlags::O_NOFOLLOW | OpenFlags::O_CLOEXEC, + Mode::empty(), + )?; + let etc = sys::open_beneath( + &directory, + c"etc", + OpenFlags::O_PATH | OpenFlags::O_DIRECTORY, + Mode::empty(), + false, + )?; + let status = nix::sys::stat::fstatat( + etc.borrowed(), + name.as_c_str(), + nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW, + )?; + if status.st_mode & nix::libc::S_IFMT != nix::libc::S_IFLNK { + return Err(Errno::EEXIST); + } + nix::unistd::unlinkat( + etc.borrowed(), + name.as_c_str(), + nix::unistd::UnlinkatFlags::NoRemoveDir, + ) +} + /// Opens the directory `name` beneath `directory`, on its mount and through no symbolic link, as /// a mount point of [`Root::mount_runtime`] at `path`, and creates it if it is missing. fn runtime_target( diff --git a/nvx-rs/src/guest/root/tests/view.rs b/nvx-rs/src/guest/root/tests/view.rs index e8150ba64..b0808f9fb 100644 --- a/nvx-rs/src/guest/root/tests/view.rs +++ b/nvx-rs/src/guest/root/tests/view.rs @@ -5,6 +5,61 @@ use crate::{ activation::{MachineIdMode, View}, }; +#[test] +fn runtime_sources_are_the_directories_beside_their_files() { + use std::ffi::OsStr; + + // A relative path of one component names a file of the current directory. + assert_eq!( + runtime_source(Path::new("machine-id")), + Some((Path::new("."), OsStr::new("machine-id"))) + ); + assert_eq!( + runtime_source(Path::new("/run/nvx/workload-machine-id")), + Some((Path::new("/run/nvx"), OsStr::new("workload-machine-id"))) + ); + assert_eq!( + runtime_source(Path::new("state/time")), + Some((Path::new("state"), OsStr::new("time"))) + ); + for path in ["", "/", "..", "time/.."] { + assert_eq!(runtime_source(Path::new(path)), None, "{path}"); + } +} + +#[test] +fn only_a_link_at_a_runtime_file_of_etc_is_removed() { + let root_with_etc = || { + let root = tempfile::tempdir().unwrap(); + fs::create_dir(root.path().join("etc")).unwrap(); + root + }; + for target in [MACHINE_ID_TARGET, RESOLVER_TARGET] { + let root = root_with_etc(); + let link = root.path().join(target.trim_start_matches('/')); + let outside = tempfile::NamedTempFile::new().unwrap(); + std::os::unix::fs::symlink(outside.path(), &link).unwrap(); + remove_link(root.path(), target).unwrap(); + assert!(std::fs::symlink_metadata(&link).is_err()); + assert!(outside.path().exists()); + assert_eq!(remove_link(root.path(), target), Err(Errno::ENOENT)); + std::fs::write(&link, b"image\n").unwrap(); + assert_eq!(remove_link(root.path(), target), Err(Errno::EEXIST)); + assert_eq!(std::fs::read(&link).unwrap(), b"image\n"); + // A link on the way to it is not followed. + let linked = tempfile::tempdir().unwrap(); + std::os::unix::fs::symlink(root.path().join("etc"), linked.path().join("etc")).unwrap(); + assert_eq!(remove_link(linked.path(), target), Err(Errno::ELOOP)); + assert_eq!(std::fs::read(&link).unwrap(), b"image\n"); + } + // Only a file of `/etc` is a runtime file there. + let root = root_with_etc(); + assert_eq!( + remove_link(root.path(), TIME_STATUS_TARGET), + Err(Errno::EINVAL) + ); +} + /// Returns a root and its view files, with no view bound yet. fn prepared() -> (Guest, Root, MachineId, PathBuf) { let guest = Guest::new(); From 0f9a26b29999b2184e1199c100a2a7f0e4d09ca2 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 04:22:11 -0800 Subject: [PATCH 4/6] guest: name entry test types explicitly Keep legacy entry tests independent of the production imports used by shared file bindings and account checks. Preserve their existing assertions and production behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f407efdc-4592-4fb9-8abc-095402b6df7b Copilot-Session: a773baad-9362-4d2f-be37-d4f9eda25cee --- nvx-rs/src/guest/entry/tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nvx-rs/src/guest/entry/tests.rs b/nvx-rs/src/guest/entry/tests.rs index 58b2e7804..fcd12a7e1 100644 --- a/nvx-rs/src/guest/entry/tests.rs +++ b/nvx-rs/src/guest/entry/tests.rs @@ -13,10 +13,11 @@ use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::PermissionsExt; use super::*; +use crate::guest::mappings::{Access, Step}; use crate::guest::root::tests::{Call, FakeKernel, Operation, attach_calls}; use crate::guest::spawn::{Command, LaunchFailure, LaunchStage}; -use crate::guest::sys::ChildStatus; use crate::guest::sys::tests::Simulation; +use crate::guest::sys::{ChildStatus, Errno}; use crate::protocol::cmdline::write_resolver; impl Prepared { From b3ff107b60442c60336820d63902365ef89d5dc8 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sat, 7 Nov 2026 00:08:55 -0800 Subject: [PATCH 5/6] 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 207548c5cf04626eaaf021a42684e562f58f8491 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sun, 8 Nov 2026 17:00:06 -0800 Subject: [PATCH 6/6] 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() };