diff --git a/nvx-rs/README.md b/nvx-rs/README.md index db0bc0b8b..bc5bde8a3 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 | @@ -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 @@ -504,13 +505,42 @@ 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. 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` 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` `SnapshotKey` and `SnapshotManifest` own the compatibility key and strict @@ -1905,7 +1935,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 +1949,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..fd53490f6 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 @@ -3353,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 @@ -3369,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/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/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/entry.rs b/nvx-rs/src/guest/entry.rs index d373c4514..b63cb58db 100644 --- a/nvx-rs/src/guest/entry.rs +++ b/nvx-rs/src/guest/entry.rs @@ -126,22 +126,20 @@ 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}; +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,16 +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; -/// 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] = [ "CAP_CHOWN", @@ -438,70 +426,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,105 +790,7 @@ 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) - } - 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, - ) + bind_file_retained(kernel, root, path, source, target).map_err(|failure| failure.error) } /// One execution that an [`Entry`] prepared, which [`Command::enter`] takes. diff --git a/nvx-rs/src/guest/entry/tests.rs b/nvx-rs/src/guest/entry/tests.rs index efc43e04d..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 { @@ -472,81 +473,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); @@ -802,56 +728,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/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..d769d3f0f 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 @@ -189,10 +189,14 @@ 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::{Kind, 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}; use crate::protocol::root::RAM_UPPER_CAPS; use crate::uuid::Uuid; @@ -202,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. @@ -645,6 +652,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,14 +740,18 @@ 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, /// 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. @@ -760,8 +773,58 @@ impl Root { &self.runtime } - /// 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. + /// 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 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 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, 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 } @@ -791,7 +854,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 @@ -949,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, @@ -1363,7 +1426,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 +1546,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) @@ -1463,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.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..b0808f9fb --- /dev/null +++ b/nvx-rs/src/guest/root/tests/view.rs @@ -0,0 +1,262 @@ +use super::*; +use crate::guest::view::{self as vm_view, MACHINE_ID_FILE, RESOLVER_FILE}; +use crate::{ + ErrorKind, + 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(); + 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_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(); + 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()); + 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); + } +} + +#[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..a9003c025 --- /dev/null +++ b/nvx-rs/src/guest/view/tests.rs @@ -0,0 +1,113 @@ +use super::*; +use std::net::{Ipv4Addr, Ipv6Addr}; +use std::os::unix::fs::{PermissionsExt, symlink}; + +#[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 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 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); + 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()); +} diff --git a/nvx-rs/src/host/platform/tests.rs b/nvx-rs/src/host/platform/tests.rs index 78edf9022..100607c0f 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, claim_released_within, 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..67c72a317 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(); @@ -649,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/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..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; @@ -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. @@ -244,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 @@ -294,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)] @@ -328,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(), @@ -337,6 +368,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 +395,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..38592dedd 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -1,13 +1,19 @@ 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; -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 @@ -428,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 @@ -757,3 +763,526 @@ 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_command() -> (PathBuf, Vec) { + #[cfg(target_os = "linux")] + let command = (PathBuf::from("/bin/sleep"), vec![OsString::from("60")]); + #[cfg(windows)] + 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() + }; + 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_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() { + 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!( + 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()); +} + +#[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, + hypervisor: Hypervisor::from_env_or_default().unwrap(), + 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); +}