diff --git a/nvx-rs/README.md b/nvx-rs/README.md index a7a093985..db0bc0b8b 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -124,8 +124,8 @@ 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) | The directory of a VM, private to the current user, with the fixed names of the files and endpoints of its launches | -| [`nvx::host::snapshot`](#nvxhostsnapshot) | Snapshot compatibility keys, strict manifests, and restore-time eligibility | +| [`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::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 | | [`nvx::host::artifacts`](#nvxhostartifacts) | Locating the OpenVMM executable and the guest artifacts that a VM runs, and their runtime identity | @@ -485,6 +485,32 @@ credential that the host presents at a VM's control endpoint: `as_bytes` returns it, and its `Debug` form hides it. The library never writes it to a file; a caller that reconnects after a restart keeps it. +`vm::create(&runtime, &config, None, &dir)` creates an inspectable cold +`Prepared` launch without starting a process or binding a listener. It requires +`config.boot`, refuses a snapshot input, rechecks the directory's privacy, and +creates a new private log. Existing log, outcome and Linux endpoint entries +are refused rather than replaced. Only the log is created before a spawn; +OpenVMM creates the remaining outputs when it runs. A failed preparation +closes and removes only its new log, preserving all pre-existing entries and +caller-owned inputs, and reports a cleanup failure with the original error. + +On Linux, the generated control and boot-console socket names must be valid +Unicode and shorter than 108 bytes both as supplied and with the directory's +ancestor links resolved. Supplied names must hold no comma or equals sign, +which OpenVMM's listener options cannot represent literally. Creation refuses +these names before creating any output, including the log. + +`Prepared::capability()` and `outputs()` return what the caller may journal +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 +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. + ### `nvx::host::snapshot` `SnapshotKey` and `SnapshotManifest` own the compatibility key and strict @@ -498,6 +524,10 @@ The key's canonical encoding and identity use the snapshot names and omit and no reader or migration of the prior encoding is supported. `CaptureClass` still records the legacy capture inputs, including its guest build. +`Snapshot` is an opaque input of a future restore. `vm::create` currently +refuses `Some(snapshot)` with `ErrorKind::Unsupported`; only cold boots are +supported. + ### `nvx::host::config` `VmConfig`: the configuration of a VM as one value, its processors, its memory @@ -560,9 +590,9 @@ entries. The table's `HostMap` and its wire codecs are accessible through command-line budget, including the terminating NUL. The legacy launch checks continue to use it. -`VmConfig::openvmm_arguments` renders a cold boot's whole OpenVMM argument list -from `Runtime` and `VmConfig`. Its borrowed control endpoint, report path, and -optional boot-console endpoint are already chosen output names, not additional +The internal whole-config renderer used by `vm::create` renders a cold boot's +OpenVMM arguments from `Runtime` and `VmConfig`. Its borrowed control endpoint, +report path, and optional boot-console endpoint are already chosen output names, not additional launch settings. It opens no path, allocates no endpoint, and starts no process. It renders processors, memory without narrowing it, kernel and initramfs paths, the kernel command line, layered-root block devices by path, network and export @@ -572,8 +602,8 @@ its supplied console name; a disconnected attachment retains the device and discards its output. Listener names are nonempty and contain no NUL, comma, or equals sign, which OpenVMM's serial options cannot hold literally. The renderer emits neither `--microvm-lifecycle` nor -`--microvm-workload-identity`. The whole-config entry point is temporarily -public: SL2.8 makes it internal when `host::vm::create` calls it. +`--microvm-workload-identity`. Callers inspect the complete arguments through +`Prepared::arguments()`. Both argument entry points refuse a `Boot` with a capture as `Unsupported` rather than silently omit its devices and resource capacities. diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index ed3f314d2..a89d94a41 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -1810,7 +1810,6 @@ method nvx::host::config::VmConfig::kernel_command_line method nvx::host::config::VmConfig::mapping_table field nvx::host::config::VmConfig::memory_mib field nvx::host::config::VmConfig::network -method nvx::host::config::VmConfig::openvmm_arguments field nvx::host::config::VmConfig::processors field nvx::host::config::VmConfig::root field nvx::host::config::VmConfig::view @@ -2706,6 +2705,7 @@ method nvx::host::snapshot::KeyIdentity::from_bytes const nvx::host::snapshot::MANIFEST_NAME const nvx::host::snapshot::MAX_DOWNTIME_NS const nvx::host::snapshot::MAX_MANIFEST_LEN +struct nvx::host::snapshot::Snapshot struct nvx::host::snapshot::SnapshotKey const nvx::host::snapshot::SnapshotKey::MAX_LEN const nvx::host::snapshot::SnapshotKey::VERSION @@ -3353,6 +3353,12 @@ 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::Prepared +method nvx::host::vm::Prepared::arguments +method nvx::host::vm::Prepared::capability +method nvx::host::vm::Prepared::inherit +method nvx::host::vm::Prepared::log +method nvx::host::vm::Prepared::outputs struct nvx::host::vm::VmDir method nvx::host::vm::VmDir::open method nvx::host::vm::VmDir::path @@ -3361,6 +3367,7 @@ field nvx::host::vm::VmOutputs::console field nvx::host::vm::VmOutputs::endpoint field nvx::host::vm::VmOutputs::log field nvx::host::vm::VmOutputs::outcome +fn nvx::host::vm::create fn nvx::host::vm::create_private_dir mod nvx::net struct nvx::net::Cidr diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index 2a2a3f121..558c5d37e 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -733,7 +733,6 @@ method nvx::host::config::VmConfig::kernel_command_line method nvx::host::config::VmConfig::mapping_table field nvx::host::config::VmConfig::memory_mib field nvx::host::config::VmConfig::network -method nvx::host::config::VmConfig::openvmm_arguments field nvx::host::config::VmConfig::processors field nvx::host::config::VmConfig::root field nvx::host::config::VmConfig::view @@ -1629,6 +1628,7 @@ method nvx::host::snapshot::KeyIdentity::from_bytes const nvx::host::snapshot::MANIFEST_NAME const nvx::host::snapshot::MAX_DOWNTIME_NS const nvx::host::snapshot::MAX_MANIFEST_LEN +struct nvx::host::snapshot::Snapshot struct nvx::host::snapshot::SnapshotKey const nvx::host::snapshot::SnapshotKey::MAX_LEN const nvx::host::snapshot::SnapshotKey::VERSION @@ -2275,6 +2275,12 @@ 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::Prepared +method nvx::host::vm::Prepared::arguments +method nvx::host::vm::Prepared::capability +method nvx::host::vm::Prepared::inherit +method nvx::host::vm::Prepared::log +method nvx::host::vm::Prepared::outputs struct nvx::host::vm::VmDir method nvx::host::vm::VmDir::open method nvx::host::vm::VmDir::path @@ -2283,6 +2289,7 @@ field nvx::host::vm::VmOutputs::console field nvx::host::vm::VmOutputs::endpoint field nvx::host::vm::VmOutputs::log field nvx::host::vm::VmOutputs::outcome +fn nvx::host::vm::create fn nvx::host::vm::create_private_dir mod nvx::net struct nvx::net::Cidr diff --git a/nvx-rs/src/host/config/mod.rs b/nvx-rs/src/host/config/mod.rs index fc5fc6bda..1e0e25b6a 100644 --- a/nvx-rs/src/host/config/mod.rs +++ b/nvx-rs/src/host/config/mod.rs @@ -3,8 +3,9 @@ //! of it, its [`NetworkConfig`], the host paths that it exports, its [`ConsoleAttachment`], and //! what only its cold boot takes, a [`Boot`], with what a capture of the VM adds, a //! [`CaptureConfig`], whose [`CaptureIdentity`] names the capture's snapshots. -//! [`VmConfig::openvmm_arguments`] renders a whole cold launch from that configuration and a -//! [`Runtime`], with caller-supplied output names, without opening paths or allocating endpoints. +//! An internal renderer builds a whole cold launch for [`crate::host::vm::create`], +//! from that configuration and a +//! [`Runtime`], without opening runtime paths or allocating endpoints. use std::collections::BTreeMap; use std::ffi::{OsStr, OsString}; @@ -447,8 +448,7 @@ impl VmConfig { /// contain neither commas nor equals signs, which delimit OpenVMM's serial options. /// The caller runs `runtime.openvmm` with these arguments. /// - /// This whole-config entry point is temporarily public. SL2.8 makes it internal when - /// `host::vm::create` calls it. Individual device lowerings stay internal. Neither managed + /// [`crate::host::vm::create`] calls this internal whole-config renderer. Neither managed /// lifecycle nor workload-identity flags are rendered; the caller owns its guest tokens. /// `runtime.windows_breakaway` is process-launch behavior, not an OpenVMM argument. /// @@ -461,7 +461,7 @@ impl VmConfig { /// kernel text with double quotes or the standalone `--` delimiter, or a complete command line /// beyond the Windows limit. /// The complete limit check runs on every host, without a reserve. - pub fn openvmm_arguments( + pub(crate) fn openvmm_arguments( &self, runtime: &Runtime, endpoint: &str, @@ -553,8 +553,8 @@ impl VmConfig { /// /// Like legacy adapter planning, the reserve foresees runtime-file and output names of up to /// 260 code units each, Windows' `MAX_PATH`. It does not certify arbitrary runtime paths or - /// CPU-profile names: [`openvmm_arguments`](Self::openvmm_arguments) checks the complete - /// command line with the actual runtime and output names, without a reserve. + /// CPU-profile names: [`crate::host::vm::create`] checks the complete command line with the + /// actual runtime and output names, without a reserve. /// A listener's backend prefix is counted here; its endpoint name stays in the reserve. /// /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a refused diff --git a/nvx-rs/src/host/mod.rs b/nvx-rs/src/host/mod.rs index bc7a29a62..950a59525 100644 --- a/nvx-rs/src/host/mod.rs +++ b/nvx-rs/src/host/mod.rs @@ -50,7 +50,9 @@ //! [`PreparedLaunch`](sandbox::PreparedLaunch) hands the VMM the files that those checks //! opened, which it inherits through the [`supervisor`]'s allowlist. //! - [`vm`] holds a caller-owned VM directory, private to the current user, the fixed names of -//! its files and endpoints, and its control credential, which the library never writes. +//! its files and endpoints, and a checked cold [`Prepared`](vm::Prepared) launch with its +//! opened log and control credential, which the library never writes. The opaque +//! [`Snapshot`](snapshot::Snapshot) input of a future restore is refused by cold creation. //! - [`restore`] orders caller-driven activation to Ready without I/O or a clock, and listens for //! OpenVMM's restore-ready event from the held VM process. //! - `cli` (feature `cli`) is the `nvx-rs` command-line interface, which runs microVMs and diff --git a/nvx-rs/src/host/snapshot.rs b/nvx-rs/src/host/snapshot.rs index 4fde64985..599a0fb53 100644 --- a/nvx-rs/src/host/snapshot.rs +++ b/nvx-rs/src/host/snapshot.rs @@ -1,4 +1,4 @@ -//! Snapshot metadata: compatibility keys, strict manifests and restore-time eligibility. +//! Snapshot metadata and the opaque input of a future VM restore. //! //! [`SnapshotKey`] derives a compatibility key from a capture's class and manifest. //! [`SnapshotManifest::read`] reads the manifest that OpenVMM wrote, and @@ -36,3 +36,10 @@ pub use self::key::{CaptureClass, ClassIdentity, Invariants, KeyIdentity, Snapsh pub use self::manifest::{ BlockGeometry, HostClock, MANIFEST_NAME, MAX_MANIFEST_LEN, SnapshotManifest, TimeContract, }; + +/// An opaque snapshot input. Creating a VM from a snapshot is not supported yet. +#[derive(Debug)] +pub struct Snapshot { + /// Reserved for snapshot metadata. + pub(crate) _private: (), +} diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index daabf3921..9aced91f2 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -10,15 +10,22 @@ //! which OpenVMM gives its temporary files. [`Capability`] holds the credential of a VM's control //! endpoint, which the library never writes to a file. +use std::ffi::OsString; use std::fmt; +use std::fs::{self, File, OpenOptions}; use std::io; use std::path::{Path, PathBuf}; +use crate::host::config::{ConsoleAttachment, VmConfig}; +use crate::host::filesystem; use crate::host::paths::{absolute, normalized}; +use crate::host::platform; #[cfg(not(windows))] use crate::host::platform::{check_store_dir as check_dir, create_store_dir as create_dir}; #[cfg(windows)] 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::protocol::CAPABILITY_LEN; use crate::{Error, Result}; @@ -98,10 +105,6 @@ impl VmDir { } /// Returns a launch's outputs, with a boot-console endpoint if requested. Creates nothing. - #[cfg_attr( - not(test), - expect(dead_code, reason = "SL2.8's vm::create calls it (#1099)") - )] pub(crate) fn outputs(&self, console: bool) -> Result { Ok(VmOutputs { log: self.path.join(LOG), @@ -189,6 +192,185 @@ impl fmt::Debug for Capability { } } +/// A checked cold launch, with its capability, chosen outputs, and already opened log. +/// +/// Creation starts no process and binds no listener. The caller keeps the VM directory and +/// runtime inputs stable, and may journal the capability and outputs before a later spawn. +/// Dropping a successful preparation closes its files but leaves its output paths to the caller. +#[derive(Debug)] +pub struct Prepared { + /// The control credential, never written to disk. + capability: Capability, + /// The launch's fixed file paths and chosen endpoints. + outputs: VmOutputs, + /// The newly created log, kept open rather than reopened by name. + log: File, + /// The complete arguments, checked against the actual runtime and output paths. + arguments: Vec, + /// Already opened resources added by the caller, retained until the preparation is dropped. + inherit: Vec, +} + +impl Prepared { + /// Returns the credential to journal before a spawn or use when attaching a session. + pub fn capability(&self) -> &Capability { + &self.capability + } + + /// Returns the launch's log, outcome, control, and optional boot-console names. + pub fn outputs(&self) -> &VmOutputs { + &self.outputs + } + + /// Adds an already opened resource to the inheritance allowlist, transferring ownership. + /// + /// The resource is not reopened, and remains open for the lifetime of this preparation. + pub fn inherit(&mut self, file: File) { + self.inherit.push(file); + } + + /// Returns the same opened log that creation made, for example to take a liveness claim. + pub fn log(&self) -> &File { + &self.log + } + + /// Returns the complete, checked OpenVMM arguments without starting a process. + pub fn arguments(&self) -> &[OsString] { + &self.arguments + } +} + +/// Creates an inspectable cold launch in `dir`, without starting a process or binding endpoints. +/// +/// 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`. +/// +/// 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 +/// replaced. A failure closes and removes only the log that this invocation created, preserving +/// every pre-existing entry and the caller's inputs. The capability is never written to disk. +/// +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a restore, a missing +/// boot, a refused configuration, unrepresentable Linux socket names, or a complete Windows +/// command line beyond the portable limit. +/// The final check includes the actual runtime and output paths, with no reserve, on every host. +/// Filesystem failures have kind [`ErrorKind::Io`](crate::ErrorKind::Io), retaining their source; +/// a rollback failure is reported along with the original error. +pub fn create( + runtime: &Runtime, + config: &VmConfig, + from: Option<&Snapshot>, + dir: &VmDir, +) -> Result { + if from.is_some() { + return Err(Error::unsupported( + "creating a VM from a Snapshot is not supported yet", + )); + } + if config.boot.is_none() { + return Err(Error::unsupported("creating a cold VM needs Boot")); + } + VmDir::open(dir.path())?; + platform::check_working_dir(dir.path())?; + let outputs = dir.outputs(config.console == ConsoleAttachment::Listener)?; + if !cfg!(windows) { + for endpoint in std::iter::once(&outputs.endpoint).chain(outputs.console.as_ref()) { + if endpoint.len() >= 108 || endpoint.contains([',', '=']) { + return Err(Error::unsupported(format!( + "the VM socket {endpoint} must be shorter than 108 bytes and hold no ',' or '='", + ))); + } + } + let canonical = filesystem::canonicalize(dir.path()) + .map_err(|error| io_error("resolve", dir.path(), error))?; + for name in std::iter::once(CONTROL).chain(outputs.console.as_ref().map(|_| CONSOLE)) { + let endpoint = canonical.join(name); + if endpoint.to_str().is_none() || endpoint.as_os_str().len() >= 108 { + return Err(Error::unsupported(format!( + "the canonical VM socket {} must be valid Unicode and shorter than 108 bytes", + endpoint.display(), + ))); + } + } + } + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let log = options.open(&outputs.log).map_err(|error| { + Error::io(format!( + "cannot create the VM log {}", + outputs.log.display() + )) + .with_source(error) + })?; + let checked = (|| { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + log.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|error| { + Error::io("cannot make the new VM log private").with_source(error) + })?; + } + if let Some(problem) = platform::private_file_problem(&log) + .map_err(|error| Error::io("cannot check the new VM log").with_source(error))? + { + return Err(Error::io(format!("the new VM log {problem}"))); + } + unused_output(&outputs.outcome)?; + if !cfg!(windows) { + unused_output(Path::new(&outputs.endpoint))?; + if let Some(console) = &outputs.console { + unused_output(Path::new(console))?; + } + } + let capability = Capability(crate::host::instance::generate_capability()?); + let arguments = config.openvmm_arguments( + runtime, + &outputs.endpoint, + &outputs.outcome, + outputs.console.as_deref(), + )?; + Ok((capability, arguments)) + })(); + match checked { + Ok((capability, arguments)) => Ok(Prepared { + capability, + outputs, + log, + arguments, + inherit: Vec::new(), + }), + Err(error) => { + drop(log); + match fs::remove_file(&outputs.log) { + Ok(()) => Err(error), + Err(cleanup) if cleanup.kind() == io::ErrorKind::NotFound => Err(error), + Err(cleanup) => Err(Error::new( + error.kind(), + format!( + "{error}; cannot remove the created VM log {}: {cleanup}", + outputs.log.display() + ), + ) + .with_source(error)), + } + } + } +} + +/// Refuses an existing output entry without opening, replacing, or removing it. +fn unused_output(path: &Path) -> Result<()> { + let error = match fs::symlink_metadata(path) { + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => error, + Ok(_) => io::Error::new(io::ErrorKind::AlreadyExists, "the output already exists"), + }; + Err(Error::io(format!("cannot reserve VM output {}", path.display())).with_source(error)) +} + /// Retains the host's I/O failure, or classifies an unsupported host as unavailable. fn io_error(action: &str, path: &Path, error: io::Error) -> Error { let message = format!("cannot {action} the VM directory {}", path.display()); diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index c8ed0d350..466b51654 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -2,6 +2,12 @@ use std::fs; use super::*; use crate::ErrorKind; +use crate::activation::{MachineIdMode, View}; +use crate::host::config::{ + Boot, PROCESS_COMMAND_LINE_LIMIT, RootConfig, process_command_line_units, +}; +use crate::host::runtime::{ArtifactIdentity, LaunchOptions, RuntimeIdentity}; +use crate::hypervisor::Hypervisor; fn is_fresh_pipe(endpoint: &str, prefix: &str) -> bool { endpoint @@ -399,3 +405,355 @@ fn creating_and_opening_a_vm_directory_writes_no_capability_file_or_output() { .collect(); assert_eq!(entries, ["vm"]); } + +fn cold_config() -> VmConfig { + VmConfig { + processors: 1, + memory_mib: 256, + root: RootConfig::Blockless, + view: View { + hostname: b"box".to_vec(), + machine_id: MachineIdMode::Image, + }, + network: None, + filesystem: None, + console: ConsoleAttachment::Listener, + boot: Some(Boot { + command_line: "nvx_entrypoint=/bin/sh nvx_arg=-c nvx_arg=exit".to_owned(), + capture: None, + }), + } +} + +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"), + identity: RuntimeIdentity::new(identity.clone(), identity.clone(), identity), + hypervisor: if cfg!(windows) { + Hypervisor::Whp + } else { + Hypervisor::Mshv + }, + cpu_profile: None, + windows_breakaway: false, + options: LaunchOptions::default(), + } +} + +#[test] +fn cold_creation_is_inspectable_without_a_process_listener_or_capability_file() { + for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { + let (_directory, vm) = vm_dir(); + let runtime = cold_runtime(); + let config = VmConfig { + console, + ..cold_config() + }; + let prepared = create(&runtime, &config, None, &vm).unwrap(); + let outputs = prepared.outputs(); + assert_eq!(outputs.log, vm.path().join("openvmm.log")); + assert_eq!( + outputs.console.is_some(), + console == ConsoleAttachment::Listener + ); + assert!(!outputs.outcome.exists()); + assert_eq!(prepared.capability().as_bytes().len(), CAPABILITY_LEN); + assert_ne!(prepared.capability().as_bytes(), &[0; CAPABILITY_LEN]); + assert_eq!( + prepared.arguments(), + config + .openvmm_arguments( + &runtime, + &outputs.endpoint, + &outputs.outcome, + outputs.console.as_deref() + ) + .unwrap() + ); + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 1); + drop(prepared); + assert!(vm.path().join(LOG).exists()); + } +} + +#[test] +fn a_missing_boot_and_a_snapshot_are_refused_before_any_output_is_created() { + let (_directory, vm) = vm_dir(); + let runtime = cold_runtime(); + let mut config = cold_config(); + config.boot = None; + let snapshot = Snapshot { _private: () }; + for (from, config, expected) in [ + (None, &config, "Boot"), + (Some(&snapshot), &cold_config(), "Snapshot"), + ] { + let error = create(&runtime, config, from, &vm).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert!(error.to_string().contains(expected), "{error}"); + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 0); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn cold_creation_refuses_listener_delimiters_before_creating_outputs() { + for delimiter in [',', '='] { + for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(format!("vm{delimiter}option")); + create_private_dir(&path).unwrap(); + let vm = VmDir::open(&path).unwrap(); + let input = path.join("caller-input"); + fs::write(&input, b"borrowed input").unwrap(); + let config = VmConfig { + console, + ..cold_config() + }; + let error = create(&cold_runtime(), &config, None, &vm).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert_eq!(fs::read(&input).unwrap(), b"borrowed input"); + assert_eq!(fs::read_dir(&path).unwrap().count(), 1); + } + } +} + +#[cfg(target_os = "linux")] +#[test] +fn cold_creation_checks_socket_path_byte_limits_before_creating_outputs() { + use std::os::unix::net::UnixListener; + + for socket_bytes in [107usize, 108, 109] { + for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { + let directory = tempfile::tempdir().unwrap(); + let name_bytes = socket_bytes + .checked_sub(directory.path().as_os_str().len() + CONTROL.len() + 2) + .expect("a short temporary directory is needed for the socket boundary"); + let prefix = "unicode space "; + let remaining = name_bytes.checked_sub(prefix.len()).unwrap(); + let mut name = format!("{prefix}{}", "\u{e9}".repeat(remaining / 2)); + name.push_str(&"x".repeat(remaining % 2)); + let path = directory.path().join(name); + create_private_dir(&path).unwrap(); + let vm = VmDir::open(&path).unwrap(); + let outputs = vm.outputs(console == ConsoleAttachment::Listener).unwrap(); + assert_eq!(outputs.endpoint.len(), socket_bytes); + assert!(outputs.endpoint.encode_utf16().count() < socket_bytes); + let input = path.join("caller-input"); + fs::write(&input, b"borrowed input").unwrap(); + let config = VmConfig { + console, + ..cold_config() + }; + let result = create(&cold_runtime(), &config, None, &vm); + if socket_bytes == 107 { + let prepared = result.unwrap(); + for endpoint in std::iter::once(&prepared.outputs().endpoint) + .chain(prepared.outputs().console.as_ref()) + { + let listener = UnixListener::bind(endpoint).unwrap(); + assert_eq!( + listener.local_addr().unwrap().as_pathname(), + Some(Path::new(endpoint)) + ); + } + } else { + let error = result.unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert_eq!(fs::read_dir(&path).unwrap().count(), 1); + } + assert_eq!(fs::read(&input).unwrap(), b"borrowed input"); + } + } +} + +#[cfg(target_os = "linux")] +#[test] +fn cold_creation_checks_canonical_socket_byte_limits_through_ancestor_symlinks() { + use std::os::unix::fs::symlink; + use std::os::unix::net::UnixListener; + + for socket_bytes in [107usize, 108, 109] { + for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { + let directory = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(directory.path()).unwrap(); + let name_bytes = socket_bytes + .checked_sub(root.as_os_str().len() + CONTROL.len() + 5) + .expect("a short temporary directory is needed for the socket boundary"); + let prefix = "unicode space,= "; + let remaining = name_bytes.checked_sub(prefix.len()).unwrap(); + let mut name = format!("{prefix}{}", "\u{e9}".repeat(remaining / 2)); + name.push_str(&"x".repeat(remaining % 2)); + let target = root.join(name); + fs::create_dir(&target).unwrap(); + let path = target.join("vm"); + create_private_dir(&path).unwrap(); + let alias = root.join("alias"); + symlink(&target, &alias).unwrap(); + let vm = VmDir::open(&alias.join("vm")).unwrap(); + let outputs = vm.outputs(console == ConsoleAttachment::Listener).unwrap(); + assert!(outputs.endpoint.len() < socket_bytes); + assert_eq!(path.join(CONTROL).as_os_str().len(), socket_bytes); + let input = path.join("caller-input"); + fs::write(&input, b"borrowed input").unwrap(); + let config = VmConfig { + console, + ..cold_config() + }; + let result = create(&cold_runtime(), &config, None, &vm); + if socket_bytes == 107 { + let prepared = result.unwrap(); + assert_eq!(prepared.outputs(), &outputs); + for name in std::iter::once(CONTROL) + .chain(prepared.outputs().console.as_ref().map(|_| CONSOLE)) + { + let resolved = path.join(name); + let listener = UnixListener::bind(&resolved).unwrap(); + assert_eq!( + listener.local_addr().unwrap().as_pathname(), + Some(resolved.as_path()) + ); + } + } else { + let error = result.unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert_eq!(fs::read_dir(&path).unwrap().count(), 1); + } + assert_eq!(fs::read(&input).unwrap(), b"borrowed input"); + assert!( + fs::symlink_metadata(&alias) + .unwrap() + .file_type() + .is_symlink() + ); + } + } +} + +#[cfg(target_os = "linux")] +#[test] +fn cold_creation_refuses_non_unicode_canonical_sockets_through_ancestor_symlinks() { + use std::os::unix::ffi::OsStringExt; + use std::os::unix::fs::symlink; + + for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { + let directory = tempfile::tempdir().unwrap(); + let target = directory + .path() + .join(OsString::from_vec(b"parent-\xff".to_vec())); + fs::create_dir(&target).unwrap(); + let path = target.join("vm"); + create_private_dir(&path).unwrap(); + let alias = directory.path().join("alias"); + symlink(&target, &alias).unwrap(); + let vm = VmDir::open(&alias.join("vm")).unwrap(); + assert!(vm.path().to_str().is_some()); + assert!(fs::canonicalize(vm.path()).unwrap().to_str().is_none()); + let input = path.join("caller-input"); + fs::write(&input, b"borrowed input").unwrap(); + let config = VmConfig { + console, + ..cold_config() + }; + let error = create(&cold_runtime(), &config, None, &vm).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert!(error.to_string().contains("Unicode"), "{error}"); + assert_eq!(fs::read(&input).unwrap(), b"borrowed input"); + assert_eq!(fs::read_dir(&path).unwrap().count(), 1); + assert!( + fs::symlink_metadata(&alias) + .unwrap() + .file_type() + .is_symlink() + ); + } +} + +#[test] +fn creation_rolls_back_its_log_and_never_removes_preexisting_outputs_or_inputs() { + let mut names = vec![LOG, OUTCOME]; + if !cfg!(windows) { + names.extend([CONTROL, CONSOLE]); + } + for name in names { + let (_directory, vm) = vm_dir(); + let existing = vm.path().join(name); + let input = vm.path().join("caller-input"); + fs::write(&existing, b"preexisting output").unwrap(); + fs::write(&input, b"borrowed input").unwrap(); + let error = create(&cold_runtime(), &cold_config(), None, &vm).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io, "{error}"); + assert_eq!(fs::read(&existing).unwrap(), b"preexisting output"); + assert_eq!(fs::read(&input).unwrap(), b"borrowed input"); + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 2); + } +} + +#[test] +fn the_log_and_inherited_resources_stay_open_without_reopening_their_paths() { + use std::io::Write; + let (_directory, vm) = vm_dir(); + let mut prepared = create(&cold_runtime(), &cold_config(), None, &vm).unwrap(); + let log = prepared.outputs().log.clone(); + let moved_log = vm.path().join("original-log"); + fs::rename(&log, &moved_log).unwrap(); + fs::write(&log, b"replacement log").unwrap(); + prepared + .log() + .try_clone() + .unwrap() + .write_all(b"original log") + .unwrap(); + assert_eq!(fs::read(&moved_log).unwrap(), b"original log"); + assert_eq!(fs::read(&log).unwrap(), b"replacement log"); + let input = vm.path().join("caller-input"); + let moved_input = vm.path().join("original-input"); + let file = File::create(&input).unwrap(); + prepared.inherit(file); + fs::rename(&input, &moved_input).unwrap(); + fs::write(&input, b"replacement input").unwrap(); + prepared.inherit[0].write_all(b"original input").unwrap(); + assert_eq!(fs::read(&moved_input).unwrap(), b"original input"); + assert_eq!(fs::read(&input).unwrap(), b"replacement input"); +} + +#[test] +fn creation_checks_the_complete_actual_command_line_at_the_windows_boundary() { + let (_directory, vm) = vm_dir(); + let config = cold_config(); + config.check_command_line().unwrap(); + let mut runtime = cold_runtime(); + let outputs = vm.outputs(true).unwrap(); + let arguments = config + .openvmm_arguments( + &runtime, + &outputs.endpoint, + &outputs.outcome, + outputs.console.as_deref(), + ) + .unwrap(); + let length = process_command_line_units(runtime.openvmm.as_os_str(), &arguments); + runtime + .openvmm + .as_mut_os_string() + .push("x".repeat(PROCESS_COMMAND_LINE_LIMIT - length)); + let prepared = create(&runtime, &config, None, &vm).unwrap(); + assert_eq!( + process_command_line_units(runtime.openvmm.as_os_str(), prepared.arguments()), + PROCESS_COMMAND_LINE_LIMIT + ); + let log = prepared.outputs().log.clone(); + drop(prepared); + fs::remove_file(&log).unwrap(); + let input = vm.path().join("caller-input"); + fs::write(&input, b"borrowed input").unwrap(); + runtime.openvmm.as_mut_os_string().push("x"); + let error = create(&runtime, &config, None, &vm).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert!(error.to_string().contains("32768"), "{error}"); + assert!(!log.exists()); + assert_eq!(fs::read(&input).unwrap(), b"borrowed input"); + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 1); +} diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index e091f293e..2e4da2c11 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -18,7 +18,7 @@ "nvx-rs/src/host/restore.rs": 3, "nvx-rs/src/host/sandbox/plan.rs": 5, "nvx-rs/src/host/session.rs": 1, - "nvx-rs/src/host/vm.rs": 6, + "nvx-rs/src/host/vm.rs": 5, "nvx-rs/src/protocol/app.rs": 6 } } diff --git a/scripts/test_host_telemetry.py b/scripts/test_host_telemetry.py index d76644be7..5e652f89f 100644 --- a/scripts/test_host_telemetry.py +++ b/scripts/test_host_telemetry.py @@ -2062,6 +2062,20 @@ def test_a_connect_still_in_progress_is_shut_down_at_the_deadline(self): blocked = threading.Event() released = threading.Event() real_socket = socket.socket + timeout = 0.2 + requester = threading.current_thread() + deadline_started = False + + def deadline_clock() -> float: + nonlocal deadline_started + if threading.current_thread() is not requester: + return 0.0 + if not deadline_started: + deadline_started = True + return 0.0 + # Reach the deadline only after the pending connect has actually begun. + self.assertTrue(blocked.wait(5), "the pending connect never started") + return timeout class BlockingSocket(real_socket): def connect(self, address: object) -> None: @@ -2076,13 +2090,14 @@ def shutdown(self, how: int) -> None: super().shutdown(how) with ( + patch.object(host_telemetry.time, "monotonic", side_effect=deadline_clock), patch.object(socket, "socket", BlockingSocket), patch.object( host_telemetry, "AZURE_IMDS_URL", "http://127.0.0.1:9/compute" ), ): with self.assertRaises(TimeoutError): - host_telemetry.read_azure_compute_metadata(timeout=0.2) + host_telemetry.read_azure_compute_metadata(timeout=timeout) self.assertTrue(blocked.is_set()) self.assertTrue(released.is_set(), "the pending connect was never shut down")