From b93206ab3804792116ac4aae0451aa0f70892e8d Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Thu, 5 Nov 2026 11:10:00 -0800 Subject: [PATCH 01/29] host: give each VM a private directory with fixed names Add caller-owned VM directories, fixed output names and redacted control capabilities without creating launch outputs or persisting credentials. Require current-user ownership and mode 0700 on Linux, or a protected owner-only DACL on Windows, without changing existing directories. Use the crate's Result and report a non-private directory as ErrorKind::Io. Normalize trailing separators and dot components before no-follow checks so a final symbolic link is refused, while preserving parent components. Keep the existing store privacy policies unchanged. Compare Windows ACL snapshots using the same canonical SDDL SID spellings as the descriptor serializer, including well-known aliases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d334d041-d2f2-47db-be92-5564ff14f2d7 --- nvx-rs/README.md | 47 +- nvx-rs/public-api/linux.txt | 12 + nvx-rs/public-api/windows.txt | 12 + nvx-rs/src/host/mod.rs | 3 + nvx-rs/src/host/platform/tests.rs | 2 + nvx-rs/src/host/platform/windows/mod.rs | 5 +- nvx-rs/src/host/platform/windows/private.rs | 123 +++++- .../host/platform/windows/private/tests.rs | 182 ++++++++ nvx-rs/src/host/platform/windows/tests.rs | 1 + nvx-rs/src/host/vm.rs | 217 ++++++++++ nvx-rs/src/host/vm/tests.rs | 401 ++++++++++++++++++ scripts/test-layout-allowlist.json | 3 +- 12 files changed, 998 insertions(+), 10 deletions(-) create mode 100644 nvx-rs/src/host/vm.rs create mode 100644 nvx-rs/src/host/vm/tests.rs diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 7236ca3b0..b49410126 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -45,9 +45,9 @@ nvx-rs/ │ ├── error.rs nvx::Error, its ErrorKind, and its stable ErrorCode │ ├── exec/ the execution engine: the streams, credit, and outcome of an execution's two │ │ sides, without I/O, threads, or features, for any framing and host -│ ├── host/ feature `host` (default): the runtime that every launch shares, artifacts, -│ │ launch, network and egress policy files, filesystem, NVXS connection, session, -│ │ process, platform, VM instances, sandbox stores, templates' manifests, +│ ├── host/ feature `host` (default): the runtime that every launch shares, VM directories, +│ │ artifacts, launch, network and egress policy files, filesystem, NVXS connection, +│ │ session, process, platform, VM instances, sandbox stores, templates' manifests, │ │ compatibility keys, store, and leases, outcome reports, file seals, and the │ │ sandbox launch profile of `nvx-rs sandbox`, its distro and scratch bindings, │ │ and its prepared launches; with feature `cli`, the `nvx-rs` CLI @@ -121,6 +121,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) | The directory of a VM, private to the current user, with the fixed names of the files and endpoints of its launches | | [`nvx::host::artifacts`](#nvxhostartifacts) | Locating the OpenVMM executable and the guest artifacts that a VM runs, and their runtime identity | | [`nvx::host::launch`](#nvxhostlaunch) | `LaunchSpec` and `VmmLaunch`: the OpenVMM command line of a managed microVM, its checks, and the inputs that it shares with an adapter's launch | | [`nvx::host::network`](#nvxhostnetwork) | The one lowering of a network posture into OpenVMM options, for the CLI and external orchestrators | @@ -400,6 +401,46 @@ differ. The sandbox store records it with each VM, and re-exports its types. `probe_hypervisor` checks that the host has the hypervisor: its device on Linux, and the Windows Hypervisor Platform on Windows. +### `nvx::host::vm` + +The directory of a VM, which its caller creates and owns, one for each VM. +`create_private_dir` creates it private to the current user, and `VmDir::open` +opens an existing one only if it is private: on Linux, it belongs to the user +with mode 0700, which OpenVMM requires of the directory of its control socket; +on Windows, it belongs to the user, and its protected access control list holds +only entries that allow the user access, one of which allows full access, which +the files and directories created in it inherit at every depth. Neither changes +an existing directory: one that others may access fails with `ErrorKind::Io` +rather than being restricted, since they could still hold its files open. +Symbolic links and other reparse points are refused, including paths with +trailing separators or `.` components. + +The library reserves these names in the directory, and every name that starts +with `.openvmm`, which OpenVMM gives its temporary files, such as +`.openvmm.staging-*`. The caller may keep files of its own under other names. + +| Name | What it holds | +| --- | --- | +| `openvmm.log` | OpenVMM's log of a launch | +| `outcome.json` | OpenVMM's outcome report of a launch | +| `control.sock` | The control endpoint at which OpenVMM listens, on Linux | +| `console.sock` | The guest's boot console, on Linux, for a launch that listens on it | +| `restore-ready.sock` | The endpoint at which a restore awaits OpenVMM's restore-ready event, on Linux | +| `openvmm/` | OpenVMM's snapshot files: a capture's destination, and a restore's view of its snapshot | +| `key.bin`, `snapshot.json` | A finished capture's key and description, which form its snapshot with `openvmm/` | +| `capture-image.raw`, `capture-scratch.raw` | The sparse placeholder image and the dormant scratch that a capture VM attaches | + +On Windows, where OpenVMM listens on named pipes, the control and console +endpoints are fresh pipes, `//./pipe/openvmm-microvm-` and 32 random +hexadecimal digits, the prefix that OpenVMM requires of the pipe of a console, +and a restore awaits its event at a fresh `//./pipe/nvx-restore-ready-` pipe. + +`VmOutputs` holds the paths of OpenVMM's log and outcome report, its control +endpoint, and the optional boot-console endpoint. `Capability` holds the +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. + ### `nvx::host::artifacts` `Artifacts` (also `nvx::Artifacts`): the OpenVMM executable, guest kernel, and diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 933df01ac..f5d77ad4a 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -3102,6 +3102,18 @@ field nvx::host::template::TimeContract::tsc_frequency_hz field nvx::host::template::TimeContract::tsc_tolerance_ppm fn nvx::host::template::reattach_clone 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::VmDir +method nvx::host::vm::VmDir::open +method nvx::host::vm::VmDir::path +struct nvx::host::vm::VmOutputs +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_private_dir 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 2de59d619..dd07a9563 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -2039,6 +2039,18 @@ field nvx::host::template::TimeContract::tsc_frequency_hz field nvx::host::template::TimeContract::tsc_tolerance_ppm fn nvx::host::template::reattach_clone 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::VmDir +method nvx::host::vm::VmDir::open +method nvx::host::vm::VmDir::path +struct nvx::host::vm::VmOutputs +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_private_dir mod nvx::net struct nvx::net::Cidr method nvx::net::Cidr::contains diff --git a/nvx-rs/src/host/mod.rs b/nvx-rs/src/host/mod.rs index 14b7299ee..69e47ff5c 100644 --- a/nvx-rs/src/host/mod.rs +++ b/nvx-rs/src/host/mod.rs @@ -45,6 +45,8 @@ //! and [`sandbox::scratch`] binds and claims a scratch image. A //! [`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. //! - `cli` (feature `cli`) is the `nvx-rs` command-line interface, which runs microVMs and //! sandboxes, looks up and downloads artifacts, and compiles egress policies. //! @@ -90,3 +92,4 @@ mod sys; pub mod template; #[cfg(test)] pub(crate) mod tests; +pub mod vm; diff --git a/nvx-rs/src/host/platform/tests.rs b/nvx-rs/src/host/platform/tests.rs index d44a79b5f..78edf9022 100644 --- a/nvx-rs/src/host/platform/tests.rs +++ b/nvx-rs/src/host/platform/tests.rs @@ -4,6 +4,8 @@ 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}; use super::*; #[cfg(any(target_os = "linux", windows))] diff --git a/nvx-rs/src/host/platform/windows/mod.rs b/nvx-rs/src/host/platform/windows/mod.rs index 8f24c3c1d..eef623a5c 100644 --- a/nvx-rs/src/host/platform/windows/mod.rs +++ b/nvx-rs/src/host/platform/windows/mod.rs @@ -34,8 +34,9 @@ pub(crate) use self::interrupt::{interrupted, watch_interrupts}; pub(crate) use self::log_claim::{claim_log, create_log_job, log_claim_held, serves_launch}; pub use self::private::create_private_dir; pub(crate) use self::private::{ - check_owner_only_dir, check_snapshot_dir, check_store_dir, create_owner_only_dir, - create_store_dir, foreign_writer_problem, make_sparse, private_file_problem, rename_no_replace, + check_owner_only_dir, check_snapshot_dir, check_store_dir, check_vm_dir, create_owner_only_dir, + create_store_dir, create_vm_dir, foreign_writer_problem, make_sparse, private_file_problem, + rename_no_replace, }; pub use self::process::process_start_time; pub(crate) use self::process::{ diff --git a/nvx-rs/src/host/platform/windows/private.rs b/nvx-rs/src/host/platform/windows/private.rs index 29ed93fab..04d93414a 100644 --- a/nvx-rs/src/host/platform/windows/private.rs +++ b/nvx-rs/src/host/platform/windows/private.rs @@ -13,10 +13,11 @@ use windows_sys::Win32::Foundation::{ERROR_ALREADY_EXISTS, GENERIC_ALL, GENERIC_ use windows_sys::Win32::Security::{ ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, ACL_REVISION, ACL_SIZE_INFORMATION, AclSizeInformation, AddAccessAllowedAceEx, CONTAINER_INHERIT_ACE, DACL_SECURITY_INFORMATION, EqualSid, GetAce, - GetAclInformation, GetKernelObjectSecurity, GetLengthSid, GetSecurityDescriptorDacl, - GetSecurityDescriptorOwner, GetTokenInformation, InitializeAcl, InitializeSecurityDescriptor, - IsWellKnownSid, OBJECT_INHERIT_ACE, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, - SE_DACL_PROTECTED, SECURITY_ATTRIBUTES, SECURITY_DESCRIPTOR, SetSecurityDescriptorControl, + GetAclInformation, GetKernelObjectSecurity, GetLengthSid, GetSecurityDescriptorControl, + GetSecurityDescriptorDacl, GetSecurityDescriptorOwner, GetTokenInformation, INHERIT_ONLY_ACE, + InitializeAcl, InitializeSecurityDescriptor, IsWellKnownSid, NO_PROPAGATE_INHERIT_ACE, + OBJECT_INHERIT_ACE, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SE_DACL_PROTECTED, + SECURITY_ATTRIBUTES, SECURITY_DESCRIPTOR, SetSecurityDescriptorControl, SetSecurityDescriptorDacl, SetSecurityDescriptorOwner, TOKEN_QUERY, TOKEN_USER, TokenUser, WinBuiltinAdministratorsSid, WinCreatorOwnerRightsSid, WinCreatorOwnerSid, WinLocalSystemSid, }; @@ -250,6 +251,120 @@ pub(crate) fn check_snapshot_dir(path: &Path) -> io::Result> { plain_dir_problem(path, &CurrentUser::get()?, Required::Private) } +/// Creates an owner-only VM directory, or checks an existing one without changing it. +pub(crate) fn create_vm_dir(path: &Path) -> io::Result> { + create_store_dir(path)?; + check_vm_dir(path) +} + +/// Checks a VM directory's ownership, protected DACL and full, inheritable owner-only access. +/// Unlike a store, a VM directory trusts neither SYSTEM nor the Administrators or owner aliases. +pub(crate) fn check_vm_dir(path: &Path) -> io::Result> { + let directory = OpenOptions::new() + .access_mode(READ_CONTROL) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path)?; + let handle = directory.as_raw_handle() as HANDLE; + let attributes = handle_information(handle)?.dwFileAttributes; + if attributes & FILE_ATTRIBUTE_DIRECTORY == 0 || attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 + { + return Ok(Some("is not a plain directory".to_owned())); + } + let information = OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION; + let mut len = 0; + // SAFETY: an empty buffer asks for the descriptor's length. + unsafe { GetKernelObjectSecurity(handle, information, ptr::null_mut(), 0, &mut len) }; + let mut buffer = vec![0u64; (len as usize).div_ceil(mem::size_of::())]; + let descriptor: PSECURITY_DESCRIPTOR = buffer.as_mut_ptr().cast(); + // SAFETY: the handle has read-control access, and the aligned buffer holds `len` bytes. + if unsafe { GetKernelObjectSecurity(handle, information, descriptor, len, &mut len) } == 0 { + return Err(io::Error::last_os_error()); + } + vm_dir_problem(descriptor, &CurrentUser::get()?) +} + +/// Checks a VM directory's security descriptor against its current-user-only contract. +fn vm_dir_problem( + descriptor: PSECURITY_DESCRIPTOR, + user: &CurrentUser, +) -> io::Result> { + let mut owner = ptr::null_mut(); + let mut present = 0; + let mut acl = ptr::null_mut(); + let mut defaulted = 0; + let mut control = 0; + let mut revision = 0; + // SAFETY: the descriptor and output pointers are valid. The returned pointers borrow it. + let read = unsafe { + GetSecurityDescriptorOwner(descriptor, &mut owner, &mut defaulted) != 0 + && GetSecurityDescriptorDacl(descriptor, &mut present, &mut acl, &mut defaulted) != 0 + && GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) != 0 + }; + if !read { + return Err(io::Error::last_os_error()); + } + // SAFETY: both identifiers are valid. + if owner.is_null() || unsafe { EqualSid(owner, user.sid()) } == 0 { + return Ok(Some("belongs to another user".to_owned())); + } + if present == 0 || acl.is_null() { + return Ok(Some("allows every user".to_owned())); + } + if control & SE_DACL_PROTECTED == 0 { + return Ok(Some( + "inherits access control entries from its parent".to_owned(), + )); + } + // SAFETY: ACL_SIZE_INFORMATION is plain data with a valid all-zero representation. + let mut size: ACL_SIZE_INFORMATION = unsafe { mem::zeroed() }; + // SAFETY: the list is valid, and the output holds an ACL_SIZE_INFORMATION. + if unsafe { + GetAclInformation( + acl, + (&raw mut size).cast(), + mem::size_of::() as u32, + AclSizeInformation, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + let inherited = OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE; + let mut full_access = false; + for index in 0..size.AceCount { + let mut ace = ptr::null_mut(); + // SAFETY: the index is within the list, and the output pointer is valid. + if unsafe { GetAce(acl, index, &mut ace) } == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: each entry starts with an ACE_HEADER. + let header = unsafe { *ace.cast::() }; + if header.AceType != ACCESS_ALLOWED_ACE_TYPE { + return Ok(Some(format!( + "has an access control entry of type {}, not one that allows access", + header.AceType + ))); + } + let entry = ace.cast::(); + // SAFETY: an allowed ACE holds its mask and its SID from SidStart onward. + let (mask, sid): (u32, PSID) = + unsafe { ((*entry).Mask, (&raw mut (*entry).SidStart).cast()) }; + // SAFETY: both identifiers are valid. + if unsafe { EqualSid(sid, user.sid()) } == 0 { + return Ok(Some("allows others than the current user".to_owned())); + } + let flags = u32::from(header.AceFlags); + full_access |= ((mask & FILE_ALL_ACCESS) == FILE_ALL_ACCESS || mask & GENERIC_ALL != 0) + && flags & inherited == inherited + && flags & (INHERIT_ONLY_ACE | NO_PROPAGATE_INHERIT_ACE) == 0; + } + Ok((!full_access).then(|| { + "does not allow the current user full access that the files and directories created \ + in it inherit" + .to_owned() + })) +} + /// Returns why the file that `file` holds open, the lock, a record, or the capability of a /// sandbox store, is not private to the current user, if it is not. /// diff --git a/nvx-rs/src/host/platform/windows/private/tests.rs b/nvx-rs/src/host/platform/windows/private/tests.rs index e6ce375a5..25270b46b 100644 --- a/nvx-rs/src/host/platform/windows/private/tests.rs +++ b/nvx-rs/src/host/platform/windows/private/tests.rs @@ -35,6 +35,9 @@ fn private_directories_support_long_paths() { .open(directory.join("record")) .unwrap(); assert_eq!(private_file_problem(&file).unwrap(), None); + let vm = directory.join("vm"); + assert_eq!(create_vm_dir(&vm).unwrap(), None); + assert_eq!(check_vm_dir(&vm).unwrap(), None); } } @@ -113,3 +116,182 @@ pub(crate) fn set_access(path: &Path, sddl: &str) -> io::Result<()> { unsafe { LocalFree(descriptor) }; result } + +fn take_wide(text: *mut u16) -> String { + use windows_sys::Win32::Foundation::LocalFree; + + // SAFETY: the conversion returns a readable NUL-terminated string. + let len = (0..) + .take_while(|&index| unsafe { *text.add(index) } != 0) + .count(); + // SAFETY: the string holds `len` units before its NUL. + let string = String::from_utf16(unsafe { std::slice::from_raw_parts(text, len) }).unwrap(); + // SAFETY: the conversion allocated this string, which is no longer used. + unsafe { LocalFree(text.cast()) }; + string +} + +pub(crate) fn user_sid() -> String { + let user = CurrentUser::get().unwrap(); + sddl_sid(user.sid()) +} + +fn sddl_sid(sid: PSID) -> String { + use windows_sys::Win32::Security::Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, SDDL_REVISION_1, + }; + + // Use the same SID aliases as `access`'s descriptor snapshots. + // SAFETY: SECURITY_DESCRIPTOR is plain data with a valid all-zero representation. + let mut descriptor: SECURITY_DESCRIPTOR = unsafe { mem::zeroed() }; + let descriptor: PSECURITY_DESCRIPTOR = (&raw mut descriptor).cast(); + // SAFETY: the descriptor and SID are valid, and the SID outlives the conversion. + let initialized = unsafe { + InitializeSecurityDescriptor(descriptor, SECURITY_DESCRIPTOR_REVISION) != 0 + && SetSecurityDescriptorOwner(descriptor, sid, 0) != 0 + }; + assert!(initialized, "{}", io::Error::last_os_error()); + let mut text = ptr::null_mut(); + // SAFETY: the descriptor and output pointer are valid; only its owner is requested. + let converted = unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor, + SDDL_REVISION_1, + OWNER_SECURITY_INFORMATION, + &mut text, + ptr::null_mut(), + ) + }; + assert_ne!(converted, 0, "{}", io::Error::last_os_error()); + take_wide(text) + .strip_prefix("O:") + .expect("the descriptor reports its owner") + .to_owned() +} + +#[test] +fn security_identifier_spelling_matches_the_descriptor_snapshot() { + use windows_sys::Win32::Security::{ + CreateWellKnownSid, SECURITY_MAX_SID_SIZE, WinLocalServiceSid, + }; + + for (kind, expected) in [(WinLocalSystemSid, "SY"), (WinLocalServiceSid, "LS")] { + let mut buffer = + vec![0u64; (SECURITY_MAX_SID_SIZE as usize).div_ceil(mem::size_of::())]; + let sid: PSID = buffer.as_mut_ptr().cast(); + let mut length = (buffer.len() * mem::size_of::()) as u32; + // SAFETY: the aligned buffer holds at least SECURITY_MAX_SID_SIZE bytes. + let created = unsafe { CreateWellKnownSid(kind, ptr::null_mut(), sid, &mut length) }; + assert_ne!(created, 0, "{}", io::Error::last_os_error()); + assert_eq!(sddl_sid(sid), expected); + } +} + +pub(crate) fn access(path: &Path) -> io::Result { + use windows_sys::Win32::Security::Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::GetFileSecurityW; + + let name = wide_path(path)?; + let information = OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION; + let mut len = 0; + // SAFETY: the path is terminated; an empty buffer requests the descriptor's length. + unsafe { GetFileSecurityW(name.as_ptr(), information, ptr::null_mut(), 0, &mut len) }; + let mut buffer = vec![0u64; (len as usize).div_ceil(mem::size_of::())]; + let descriptor: PSECURITY_DESCRIPTOR = buffer.as_mut_ptr().cast(); + // SAFETY: the path is terminated and the aligned buffer holds `len` bytes. + if unsafe { GetFileSecurityW(name.as_ptr(), information, descriptor, len, &mut len) } == 0 { + return Err(io::Error::last_os_error()); + } + let mut text = ptr::null_mut(); + // SAFETY: the descriptor and output pointers are valid. + let converted = unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor, + SDDL_REVISION_1, + information, + &mut text, + ptr::null_mut(), + ) + }; + if converted == 0 { + return Err(io::Error::last_os_error()); + } + Ok(take_wide(text)) +} + +#[test] +fn vm_directories_must_be_owned_protected_and_allow_only_the_current_user_full_inherited_access() { + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }; + + let user = CurrentUser::get().unwrap(); + let problem = |sddl: &str| { + let text = wide(sddl); + let mut descriptor = ptr::null_mut(); + // SAFETY: the text is terminated and the output pointer is valid. + let converted = unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + text.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + ptr::null_mut(), + ) + }; + assert_ne!(converted, 0, "{sddl}: {}", io::Error::last_os_error()); + let problem = vm_dir_problem(descriptor, &user).unwrap(); + // SAFETY: the conversion allocated this descriptor, which is no longer used. + unsafe { LocalFree(descriptor) }; + problem + }; + let sid = user_sid(); + let full = format!("(A;OICI;FA;;;{sid})"); + for private in [ + format!("O:{sid}D:P{full}"), + format!("O:{sid}D:PAI{full}"), + format!("O:{sid}D:P(A;OICI;GA;;;{sid})"), + format!("O:{sid}D:P(A;OICIIO;GR;;;{sid}){full}(A;;FR;;;{sid})"), + ] { + assert_eq!(problem(&private), None, "{private}"); + } + let others = "allows others than the current user"; + let partial = "does not allow the current user full access that the files and directories created in it \ + inherit"; + for (shared, expected) in [ + (format!("O:SYD:P{full}"), "belongs to another user"), + (format!("O:BAD:P{full}"), "belongs to another user"), + (format!("O:{sid}"), "allows every user"), + (format!("O:{sid}D:NO_ACCESS_CONTROL"), "allows every user"), + ( + format!("O:{sid}D:{full}"), + "inherits access control entries from its parent", + ), + ( + format!("O:{sid}D:AI{full}"), + "inherits access control entries from its parent", + ), + (format!("O:{sid}D:P{full}(A;OICI;FA;;;SY)"), others), + (format!("O:{sid}D:P{full}(A;OICI;FA;;;BA)"), others), + (format!("O:{sid}D:P{full}(A;OICIIO;FA;;;CO)"), others), + (format!("O:{sid}D:P{full}(A;;RC;;;OW)"), others), + (format!("O:{sid}D:P{full}(A;;FR;;;WD)"), others), + ( + format!("O:{sid}D:P(D;;WD;;;{sid}){full}"), + "has an access control entry of type 1, not one that allows access", + ), + (format!("O:{sid}D:P"), partial), + // GENERIC_READ's numeric value exceeds FILE_ALL_ACCESS but grants none of its rights. + (format!("O:{sid}D:P(A;OICI;GR;;;{sid})"), partial), + (format!("O:{sid}D:P(A;OICI;0x1f01fe;;;{sid})"), partial), + (format!("O:{sid}D:P(A;;FA;;;{sid})"), partial), + (format!("O:{sid}D:P(A;OI;FA;;;{sid})"), partial), + (format!("O:{sid}D:P(A;CI;FA;;;{sid})"), partial), + (format!("O:{sid}D:P(A;OICIIO;FA;;;{sid})"), partial), + (format!("O:{sid}D:P(A;OICINP;FA;;;{sid})"), partial), + ] { + assert_eq!(problem(&shared).as_deref(), Some(expected), "{shared}"); + } +} diff --git a/nvx-rs/src/host/platform/windows/tests.rs b/nvx-rs/src/host/platform/windows/tests.rs index 971de698a..0f4f9bace 100644 --- a/nvx-rs/src/host/platform/windows/tests.rs +++ b/nvx-rs/src/host/platform/windows/tests.rs @@ -22,6 +22,7 @@ use windows_sys::Win32::System::Threading::{ use super::identity::status_error; use super::log_claim::{local_directory, log_job_id, log_job_name, log_job_processes, object_name}; pub(crate) use super::private::tests::set_access; +pub(crate) use super::private::tests::{access, user_sid}; use super::process::{end_process, wait_step}; use super::spawn::command_line; use super::*; diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs new file mode 100644 index 000000000..09893b92b --- /dev/null +++ b/nvx-rs/src/host/vm.rs @@ -0,0 +1,217 @@ +//! A caller-owned VM directory, private to the current user, with fixed names for its files and +//! endpoints. The caller may keep files of its own under other names. +//! +//! [`create_private_dir`] creates a directory private to the current user, or checks an existing +//! one, and [`VmDir::open`] opens only one that is private: mode 0700 on Linux, or an owner-only +//! protected access control list on Windows. Neither changes an existing directory, since +//! restricting its permissions would not revoke access through files that others already opened. +//! +//! The library reserves the names documented below, and every name that starts with `.openvmm`, +//! 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::fmt; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::host::paths::{absolute, normalized}; +#[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::protocol::CAPABILITY_LEN; +use crate::{Error, Result}; + +/// OpenVMM's log of a launch. +const LOG: &str = "openvmm.log"; +/// OpenVMM's outcome report of a launch. +const OUTCOME: &str = "outcome.json"; +/// The control socket on Linux. +const CONTROL: &str = "control.sock"; +/// The guest's boot-console socket on Linux. +const CONSOLE: &str = "console.sock"; +/// The restore-ready socket on Linux. +const RESTORE_READY: &str = "restore-ready.sock"; +/// OpenVMM's snapshot files: a capture's destination and a restore's snapshot view. +#[cfg_attr( + not(test), + expect(dead_code, reason = "SL2.12b and SL2.13 use it (#1102)") +)] +const OPENVMM: &str = "openvmm"; +/// A finished capture's key. +#[cfg_attr( + not(test), + expect(dead_code, reason = "SL2.12b and SL2.14 use it (#1102)") +)] +const KEY: &str = "key.bin"; +/// A finished capture's informational snapshot description. +#[cfg_attr( + not(test), + expect(dead_code, reason = "SL2.12b and SL2.14 use it (#1102)") +)] +const DESCRIPTION: &str = "snapshot.json"; +/// The sparse placeholder image that a capture VM attaches. +#[cfg_attr( + not(test), + expect(dead_code, reason = "SL2.13's capture creates it (#1102)") +)] +const CAPTURE_IMAGE: &str = "capture-image.raw"; +/// The dormant scratch that a capture VM attaches. +#[cfg_attr( + not(test), + expect(dead_code, reason = "SL2.13's capture creates it (#1102)") +)] +const CAPTURE_SCRATCH: &str = "capture-scratch.raw"; +/// The prefix OpenVMM requires of its Windows console pipes. +const OPENVMM_PIPE: &str = "openvmm-microvm"; +/// The prefix of a Windows restore-ready pipe. +const RESTORE_READY_PIPE: &str = "nvx-restore-ready"; + +/// The directory of a VM, which its caller creates and owns, private to the current user. +/// +/// The library reserves the fixed names of its launch and snapshot files and endpoints, and +/// every name that starts with `.openvmm`; the caller may add files under other names. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VmDir { + /// The directory's absolute path. + path: PathBuf, +} + +impl VmDir { + /// Opens an existing directory private to the current user, as [`create_private_dir`] makes + /// it. A relative path names a directory from the current directory. Changes nothing. + /// + /// Fails with [`ErrorKind::Io`](crate::ErrorKind::Io) if the directory is not private or + /// cannot be read, as when it does not exist. + pub fn open(path: &Path) -> Result { + // A trailing separator or `/.` would make the OS follow a final symbolic link before + // applying the platform's no-follow check. Keep `..` components unresolved. + let path = absolute(&normalized(path)).map_err(|error| io_error("open", path, error))?; + let problem = check_dir(&path).map_err(|error| io_error("open", &path, error))?; + check_privacy(&path, problem)?; + Ok(Self { path }) + } + + /// Returns the directory as an absolute path. + pub fn path(&self) -> &Path { + &self.path + } + + /// 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), + outcome: self.path.join(OUTCOME), + endpoint: self.endpoint(CONTROL, OPENVMM_PIPE)?, + console: console + .then(|| self.endpoint(CONSOLE, OPENVMM_PIPE)) + .transpose()?, + }) + } + + /// Returns a restore-ready endpoint without listening on it. + #[cfg_attr( + not(test), + expect(dead_code, reason = "SL2.17's ReadyListener calls it (#1099)") + )] + pub(crate) fn restore_ready_endpoint(&self) -> Result { + self.endpoint(RESTORE_READY, RESTORE_READY_PIPE) + } + + /// Returns the Linux socket `name` or a fresh Windows pipe with the prefix `pipe`. + fn endpoint(&self, name: &str, pipe: &str) -> Result { + if cfg!(windows) { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes) + .map_err(|error| Error::io("cannot draw the name of a pipe").with_source(error))?; + let suffix: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + return Ok(format!("//./pipe/{pipe}-{suffix}")); + } + self.path + .join(name) + .into_os_string() + .into_string() + .map_err(|_| { + Error::unsupported(format!( + "the VM directory {} is not valid Unicode, which OpenVMM's endpoints must be", + self.path.display() + )) + }) + } +} + +/// Creates a VM directory private to the current user, or checks an existing one without +/// changing it. The parent must exist. +/// +/// On Linux, a new directory has mode 0700, whatever the umask and its parent's setgid bit. On +/// Windows, it belongs to the user, and its protected access control list allows the user alone +/// full access, which the files and directories created in it inherit. +/// +/// Fails with [`ErrorKind::Io`](crate::ErrorKind::Io) if an existing directory is not private or +/// the directory cannot be created or read. +pub fn create_private_dir(path: &Path) -> Result<()> { + let path = normalized(path); + let problem = create_dir(&path).map_err(|error| io_error("create", &path, error))?; + check_privacy(&path, problem) +} + +/// A launch's output files and endpoints. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VmOutputs { + /// OpenVMM's log, `openvmm.log` in the VM directory. + pub log: PathBuf, + /// OpenVMM's outcome report, `outcome.json` in the VM directory. + pub outcome: PathBuf, + /// The control endpoint: `control.sock` in the VM directory on Linux, or on Windows a fresh + /// named pipe, `//./pipe/openvmm-microvm-` and 32 random lowercase hexadecimal digits. + pub endpoint: String, + /// The boot-console endpoint, if requested: `console.sock` in the VM directory on Linux, or + /// on Windows another fresh `//./pipe/openvmm-microvm-` pipe. + pub console: Option, +} + +/// The credential that the host presents at a VM's control endpoint. +/// +/// The library never writes it to a file, and its `Debug` form hides it. A caller that must +/// reconnect after a restart keeps the bytes that [`as_bytes`](Self::as_bytes) returns. +pub struct Capability([u8; CAPABILITY_LEN]); + +impl Capability { + /// Returns the credential's bytes. + pub fn as_bytes(&self) -> &[u8; CAPABILITY_LEN] { + &self.0 + } +} + +impl fmt::Debug for Capability { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("Capability").finish_non_exhaustive() + } +} + +/// 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()); + if error.kind() == io::ErrorKind::Unsupported { + Error::unavailable(message).with_source(error) + } else { + Error::io(message).with_source(error) + } +} + +/// Reports why an existing VM directory is not private without changing its permissions. +fn check_privacy(path: &Path, problem: Option) -> Result<()> { + problem.map_or(Ok(()), |problem| { + Err(Error::io(format!( + "the VM directory {} {problem}", + path.display() + ))) + }) +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs new file mode 100644 index 000000000..c8ed0d350 --- /dev/null +++ b/nvx-rs/src/host/vm/tests.rs @@ -0,0 +1,401 @@ +use std::fs; + +use super::*; +use crate::ErrorKind; + +fn is_fresh_pipe(endpoint: &str, prefix: &str) -> bool { + endpoint + .strip_prefix(&format!("//./pipe/{prefix}-")) + .is_some_and(|digits| { + digits.len() == 32 + && digits + .bytes() + .all(|digit| matches!(digit, b'0'..=b'9' | b'a'..=b'f')) + }) +} + +fn vm_dir() -> (tempfile::TempDir, VmDir) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("vm"); + create_private_dir(&path).unwrap(); + let vm = VmDir::open(&path).unwrap(); + (directory, vm) +} + +#[cfg(target_os = "linux")] +#[test] +fn a_vm_directory_is_created_with_mode_700_for_the_current_user_whatever_the_umask() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + use std::thread; + + let directory = tempfile::tempdir().unwrap(); + let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o7777; + let parent = directory.path().join("setgid"); + fs::create_dir(&parent).unwrap(); + fs::set_permissions(&parent, fs::Permissions::from_mode(0o2700)).unwrap(); + let path = parent.join("vm"); + 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(0o277)); + create_private_dir(&path) + }) + .join() + .unwrap() + }) + .unwrap(); + assert_eq!(mode(&path), 0o700); + assert_eq!( + fs::metadata(&path).unwrap().uid(), + nix::unistd::Uid::effective().as_raw() + ); + assert_eq!(VmDir::open(&path).unwrap().path(), path); + create_private_dir(&path).unwrap(); + assert_eq!(mode(&path), 0o700); +} + +#[cfg(target_os = "linux")] +#[test] +fn an_existing_directory_that_is_not_private_is_refused_and_left_unchanged() { + use std::os::unix::fs::{PermissionsExt, symlink}; + + use nix::sys::stat::Mode; + use nix::unistd::Uid; + + use crate::host::platform; + + let directory = tempfile::tempdir().unwrap(); + let mode = |path: &Path| fs::symlink_metadata(path).unwrap().permissions().mode() & 0o7777; + let set_mode = |path: &Path, mode: u32| { + fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap(); + }; + let refused = |path: &Path, problem: &str| { + for error in [ + create_private_dir(path).unwrap_err(), + VmDir::open(path).unwrap_err(), + ] { + assert_eq!(error.kind(), ErrorKind::Io, "{error}"); + assert_eq!( + error.to_string(), + format!("the VM directory {} {problem}", path.display()) + ); + } + }; + let path = directory.path().join("vm"); + fs::create_dir(&path).unwrap(); + for shared in [0o750, 0o705, 0o770] { + set_mode(&path, shared); + refused( + &path, + &format!("is accessible to other users (mode {shared:03o})"), + ); + assert_eq!(mode(&path), shared); + } + for other in [0o2700, 0o1700, 0o500] { + set_mode(&path, other); + refused( + &path, + &format!( + "has mode {other:03o}, not the mode 700 that the directory of a control socket \ + must have" + ), + ); + assert_eq!(mode(&path), other); + } + assert_eq!(platform::check_owner_only_dir(&path).unwrap(), None); + assert_eq!( + platform::tests::store_dir_problem(Uid::from_raw(1001), Mode::S_IRWXU, Uid::from_raw(1000)), + Some("belongs to user 1001, not to the current user 1000".to_owned()) + ); + set_mode(&path, 0o700); + let link = directory.path().join("link"); + symlink(&path, &link).unwrap(); + refused(&link, "is not a plain directory"); + let file = directory.path().join("file"); + fs::write(&file, b"").unwrap(); + refused(&file, "is not a plain directory"); +} + +#[cfg(target_os = "linux")] +#[test] +fn trailing_separators_and_current_directory_suffixes_do_not_hide_a_symbolic_link() { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let (directory, vm) = vm_dir(); + let link = directory.path().join("link"); + symlink(vm.path(), &link).unwrap(); + for suffix in ["/", "/.", "/./", "//.//"] { + let suffixed = |path: &Path| { + let mut name = path.as_os_str().to_os_string(); + name.push(suffix); + PathBuf::from(name) + }; + let path = suffixed(&link); + for error in [ + create_private_dir(&path).unwrap_err(), + VmDir::open(&path).unwrap_err(), + ] { + assert_eq!(error.kind(), ErrorKind::Io, "{error}"); + assert_eq!( + error.to_string(), + format!( + "the VM directory {} is not a plain directory", + link.display() + ) + ); + } + let private = suffixed(vm.path()); + create_private_dir(&private).unwrap(); + assert_eq!(VmDir::open(&private).unwrap().path(), vm.path()); + } + assert_eq!( + fs::metadata(vm.path()).unwrap().permissions().mode() & 0o7777, + 0o700 + ); + assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink()); + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 0); +} + +#[cfg(windows)] +#[test] +fn a_vm_directory_is_created_owner_only_and_protected_under_a_permissive_parent() { + use crate::host::platform; + + let directory = tempfile::tempdir().unwrap(); + platform::tests::set_access(directory.path(), "D:(A;OICI;FA;;;WD)").unwrap(); + let path = directory.path().join("vm"); + create_private_dir(&path).unwrap(); + let sid = platform::tests::user_sid(); + let private = format!("O:{sid}D:P(A;OICI;FA;;;{sid})"); + assert_eq!(platform::tests::access(&path).unwrap(), private); + assert_eq!(VmDir::open(&path).unwrap().path(), path); + create_private_dir(&path).unwrap(); + assert_eq!(platform::tests::access(&path).unwrap(), private); +} + +#[cfg(windows)] +#[test] +fn existing_vm_directories_that_grant_others_or_inherit_are_refused_and_left_unchanged() { + use crate::host::platform; + + let directory = tempfile::tempdir().unwrap(); + let sid = platform::tests::user_sid(); + let refused = |path: &Path, problem: &str| { + let access = platform::tests::access(path).unwrap(); + for error in [ + create_private_dir(path).unwrap_err(), + VmDir::open(path).unwrap_err(), + ] { + assert_eq!(error.kind(), ErrorKind::Io, "{error}"); + assert_eq!( + error.to_string(), + format!("the VM directory {} {problem}", path.display()) + ); + } + assert_eq!(platform::tests::access(path).unwrap(), access); + }; + let path = directory.path().join("vm"); + create_private_dir(&path).unwrap(); + let full = format!("(A;OICI;FA;;;{sid})"); + for other in ["SY", "BA", "OW"] { + platform::tests::set_access(&path, &format!("D:P{full}(A;OICI;FA;;;{other})")).unwrap(); + assert_eq!(platform::check_store_dir(&path).unwrap(), None, "{other}"); + refused(&path, "allows others than the current user"); + } + platform::tests::set_access(&path, "D:P(A;OICI;FA;;;WD)").unwrap(); + refused(&path, "allows others than the current user"); + for list in [ + format!("D:P(A;OICI;FR;;;{sid})"), + format!("D:P(A;;FA;;;{sid})"), + format!("D:P(A;OICINP;FA;;;{sid})"), + ] { + platform::tests::set_access(&path, &list).unwrap(); + refused( + &path, + "does not allow the current user full access that the files and directories \ + created in it inherit", + ); + } + platform::tests::set_access(&path, &format!("D:P(D;;WO;;;{sid}){full}")).unwrap(); + refused( + &path, + "has an access control entry of type 1, not one that allows access", + ); + platform::tests::set_access(&path, &format!("D:P{full}")).unwrap(); + let inheriting = path.join("inheriting"); + fs::create_dir(&inheriting).unwrap(); + assert_eq!(platform::check_store_dir(&inheriting).unwrap(), None); + let owned = platform::tests::access(&inheriting) + .unwrap() + .starts_with(&format!("O:{sid}D:")); + refused( + &inheriting, + if owned { + "inherits access control entries from its parent" + } else { + "belongs to another user" + }, + ); + let file = directory.path().join("file"); + fs::write(&file, b"").unwrap(); + refused(&file, "is not a plain directory"); +} + +#[test] +fn a_vm_directory_that_cannot_be_opened_or_created_fails_with_the_hosts_error() { + let directory = tempfile::tempdir().unwrap(); + let missing = directory.path().join("missing"); + let relative = Path::new("missing-vm-directory"); + for (error, action, path) in [ + (VmDir::open(&missing).unwrap_err(), "open", missing.clone()), + ( + create_private_dir(&missing.join("vm")).unwrap_err(), + "create", + missing.join("vm"), + ), + ( + VmDir::open(relative).unwrap_err(), + "open", + std::env::current_dir().unwrap().join(relative), + ), + ] { + assert_eq!(error.kind(), ErrorKind::Io, "{error}"); + assert_eq!( + error.to_string(), + format!("cannot {action} the VM directory {}", path.display()) + ); + let source = std::error::Error::source(&error).unwrap(); + let source = source.downcast_ref::().unwrap(); + assert_eq!(source.kind(), io::ErrorKind::NotFound, "{source}"); + } +} + +#[test] +fn outputs_name_the_vm_directorys_files_and_endpoints() { + let (_directory, vm) = vm_dir(); + let outputs = vm.outputs(false).unwrap(); + assert_eq!(outputs.log, vm.path().join("openvmm.log")); + assert_eq!(outputs.outcome, vm.path().join("outcome.json")); + assert_eq!(outputs.console, None); + let with_console = vm.outputs(true).unwrap(); + assert_eq!( + (&with_console.log, &with_console.outcome), + (&outputs.log, &outputs.outcome) + ); + let console = with_console.console.as_deref().unwrap(); + if cfg!(windows) { + for endpoint in [outputs.endpoint.as_str(), &with_console.endpoint, console] { + assert!(is_fresh_pipe(endpoint, "openvmm-microvm"), "{endpoint}"); + } + assert_ne!(outputs.endpoint, with_console.endpoint); + assert_ne!(with_console.endpoint, console); + } else { + let socket = |name: &str| vm.path().join(name).into_os_string().into_string().unwrap(); + assert_eq!(outputs.endpoint, socket("control.sock")); + assert_eq!(with_console.endpoint, outputs.endpoint); + assert_eq!(console, socket("console.sock")); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn the_endpoints_of_a_vm_directory_that_is_not_unicode_are_refused() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(OsStr::from_bytes(b"vm-\xff")); + create_private_dir(&path).unwrap(); + let vm = VmDir::open(&path).unwrap(); + for error in [ + vm.outputs(false).unwrap_err(), + vm.restore_ready_endpoint().unwrap_err(), + ] { + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert_eq!( + error.to_string(), + format!( + "the VM directory {} is not valid Unicode, which OpenVMM's endpoints must be", + path.display() + ) + ); + } +} + +#[test] +fn a_restore_ready_endpoint_is_the_reserved_socket_or_a_fresh_pipe() { + let (_directory, vm) = vm_dir(); + let first = vm.restore_ready_endpoint().unwrap(); + let second = vm.restore_ready_endpoint().unwrap(); + if cfg!(windows) { + for endpoint in [&first, &second] { + assert!(is_fresh_pipe(endpoint, "nvx-restore-ready"), "{endpoint}"); + } + assert_ne!(first, second); + let error = fs::File::open(first.replace('/', "\\")).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::NotFound, "{error}"); + } else { + let socket = vm.path().join("restore-ready.sock"); + assert_eq!(first, socket.into_os_string().into_string().unwrap()); + assert_eq!(second, first); + } +} + +#[test] +fn the_reserved_names_are_the_documented_ones_and_leave_the_openvmm_prefix_to_openvmm() { + let names = [ + LOG, + OUTCOME, + CONTROL, + CONSOLE, + RESTORE_READY, + OPENVMM, + KEY, + DESCRIPTION, + CAPTURE_IMAGE, + CAPTURE_SCRATCH, + ]; + assert_eq!( + names, + [ + "openvmm.log", + "outcome.json", + "control.sock", + "console.sock", + "restore-ready.sock", + "openvmm", + "key.bin", + "snapshot.json", + "capture-image.raw", + "capture-scratch.raw", + ] + ); + for name in names { + assert!(!name.starts_with(".openvmm"), "{name}"); + } +} + +#[test] +fn a_capability_gives_its_bytes_and_its_debug_hides_them() { + let bytes: [u8; CAPABILITY_LEN] = std::array::from_fn(|index| u8::try_from(index + 1).unwrap()); + let capability = Capability(bytes); + assert_eq!(capability.as_bytes(), &bytes); + assert_eq!(format!("{capability:?}"), "Capability { .. }"); + assert_eq!(format!("{capability:#?}"), "Capability { .. }"); +} + +#[test] +fn creating_and_opening_a_vm_directory_writes_no_capability_file_or_output() { + let (directory, vm) = vm_dir(); + create_private_dir(vm.path()).unwrap(); + VmDir::open(vm.path()).unwrap(); + vm.outputs(true).unwrap(); + vm.restore_ready_endpoint().unwrap(); + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 0); + let entries: Vec<_> = fs::read_dir(directory.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(entries, ["vm"]); +} diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index 5f6b123f1..3932bb153 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -13,6 +13,7 @@ "nvx-rs/src/host/cli/store/mod.rs": 1, "nvx-rs/src/host/network/address.rs": 2, "nvx-rs/src/host/platform/windows/spawn.rs": 1, - "nvx-rs/src/host/sandbox/plan.rs": 5 + "nvx-rs/src/host/sandbox/plan.rs": 5, + "nvx-rs/src/host/vm.rs": 7 } } From a5ebddead4f7768b9263ccfb7fb88b5ef8e23988 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Wed, 4 Nov 2026 12:59:45 -0800 Subject: [PATCH 02/29] host: give a VM's machine, root, view and console one checked value Add VmConfig in the new host::config: a VM's processors, its memory in MiB, its root, blockless or layered, the View that its workload has of it, the export plan of the host paths that it exports, and where its boot console's output goes, to a listener or nowhere; the console's device exists either way. A layered root names its image and format, an optional bound of a GPT disk's first partition, its overlay mode, and a scratch upper with its journal policy or a RAM upper whose cap is 1 to 90 percent of the VM's memory, 50 by default. VmConfig::check checks the whole value without reading the host, and returns the first refusal as an Error of kind Unsupported, whose message names what it refuses; an export's refusal keeps the error of ExportPlan::check as its source. It reuses activation's journal, overlay and machine-ID vocabulary, the record's hostname rule, which becomes activation::is_hostname for both, and ExportPlan::check with the guest targets that the root reserves. Add activation::View, the hostname and machine ID that a VM's workload sees, beside WorkloadView, which the record keeps for now. The public API snapshots list the new items. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 869204bf-28f8-408e-bcab-e4353944ef69 Copilot-Session: 2d81c32c-865b-42d0-8f3e-6881809e0157 --- nvx-rs/README.md | 42 +++++- nvx-rs/public-api/linux.txt | 32 +++++ nvx-rs/public-api/windows.txt | 32 +++++ nvx-rs/src/activation/mod.rs | 22 +++- nvx-rs/src/host/config/mod.rs | 165 ++++++++++++++++++++++++ nvx-rs/src/host/config/tests.rs | 218 ++++++++++++++++++++++++++++++++ nvx-rs/src/host/mod.rs | 4 + 7 files changed, 506 insertions(+), 9 deletions(-) create mode 100644 nvx-rs/src/host/config/mod.rs create mode 100644 nvx-rs/src/host/config/tests.rs diff --git a/nvx-rs/README.md b/nvx-rs/README.md index ca60516b3..d4323c888 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -45,12 +45,13 @@ nvx-rs/ │ ├── error.rs nvx::Error, its ErrorKind, and its stable ErrorCode │ ├── exec/ the execution engine: the streams, credit, and outcome of an execution's two │ │ sides, without I/O, threads, or features, for any framing and host -│ ├── host/ feature `host` (default): the runtime that every launch shares, VM directories, -│ │ artifacts, launch, network and egress policy files, filesystem, NVXS connection, -│ │ session, process, platform, VM instances, sandbox stores, templates' manifests, -│ │ compatibility keys, store, and leases, outcome reports, file seals, and the -│ │ sandbox launch profile of `nvx-rs sandbox`, its distro and scratch bindings, -│ │ and its prepared launches; with feature `cli`, the `nvx-rs` CLI +│ ├── host/ feature `host` (default): the runtime that every launch shares, VM +│ │ directories and configurations, artifacts, launch, network and egress policy +│ │ files, filesystem, NVXS connection, session, process, platform, VM instances, +│ │ sandbox stores, templates' manifests, compatibility keys, store, and leases, +│ │ outcome reports, file seals, and the sandbox launch profile of `nvx-rs sandbox`, +│ │ its distro and scratch bindings, and its prepared launches; with feature +│ │ `cli`, the `nvx-rs` CLI │ ├── testing/ feature `testing`: the fake OpenVMM │ ├── guest/ feature `guest`: the programs that run in Linux guests, the guest's NVXS │ │ connection, the libraries through which a guest init or agent opens the @@ -122,6 +123,7 @@ changes only when the module's purpose does. | [`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::config`](#nvxhostconfig) | `VmConfig`: a VM's processors, memory, root, view, export, and boot console as one value, checked without reading the host | | [`nvx::host::artifacts`](#nvxhostartifacts) | Locating the OpenVMM executable and the guest artifacts that a VM runs, and their runtime identity | | [`nvx::host::launch`](#nvxhostlaunch) | `LaunchSpec` and `VmmLaunch`: the OpenVMM command line of a managed microVM, its checks, and the inputs that it shares with an adapter's launch | | [`nvx::host::network`](#nvxhostnetwork) | The one lowering of a network posture into OpenVMM options, for the CLI and external orchestrators | @@ -290,6 +292,10 @@ and a proxy endpoint without credentials; the `WorkloadView`, its hostname, and the `ExpectedResources`. The other inputs of the root's assembly are invariants of the template. +`View` is the view that a VM's configuration, a [`VmConfig`](#nvxhostconfig), +gives its workload: its hostname and `MachineIdMode`, without an identity, which +each execution names. + `ActivationRecord::new` refuses a configuration that `Configuration::check` finds past a public limit, `MAX_MAPPINGS`, `MAX_HOSTNAME_BYTES`, or `MAX_PROXY_ENDPOINT_BYTES`, which keep every record within `MAX_RECORD_BYTES`, @@ -441,6 +447,30 @@ 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. +### `nvx::host::config` + +`VmConfig`: the configuration of a VM as one value, its processors, its memory +in MiB, its `RootConfig`, the `View` that its workload has of it, the +`ExportPlan` of the host paths that it exports, if any, and the +`ConsoleAttachment` of its boot console, a `Listener` for a client or +`Disconnected`; the console's device exists either way. + +A root is `Blockless`, the guest's own system from its initramfs, or `Layered`, +a `LayeredRoot`: the path of an image and its `RootImage`, EROFS or a GPT disk, +with the identity that the guest expects, an optional bound of a GPT disk's +first partition, the `OverlayMode`, and the `UpperConfig` of the overlay's +upper, a scratch image with its `Journal` policy, or RAM up to a cap of 1 to 90 +percent of the VM's memory, 50 when the caller has no other +(`UpperConfig::DEFAULT_MAX_MEMORY_PERCENT`). + +`VmConfig::check` checks the whole value without reading the host: it opens no +path and checks no support of the host. It fails with an `nvx::Error` of kind +`Unsupported` that names the first refusal: no processor or no memory; a layered +root without an image or a scratch path, with a bound of an EROFS image or of 0, +or with a RAM upper's cap outside 1 to 90 percent; a hostname that an activation +record refuses; or an export that `ExportPlan::check` refuses for the guest +targets that the root reserves. + ### `nvx::host::artifacts` `Artifacts` (also `nvx::Artifacts`): the OpenVMM executable, guest kernel, and diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 4952a2274..02b5bdeaf 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -226,6 +226,9 @@ field nvx::activation::Upper::Scratch::binding field nvx::activation::Upper::Scratch::journal variant nvx::activation::Upper::Tmpfs const nvx::activation::VERSION +struct nvx::activation::View +field nvx::activation::View::hostname +field nvx::activation::View::machine_id struct nvx::activation::WorkloadIdentity field nvx::activation::WorkloadIdentity::gid field nvx::activation::WorkloadIdentity::uid @@ -1746,6 +1749,35 @@ fn nvx::host::artifacts::check_manifest fn nvx::host::artifacts::openvmm_executable mod nvx::host::cli (hidden) fn nvx::host::cli::main (hidden) +mod nvx::host::config +enum nvx::host::config::ConsoleAttachment +variant nvx::host::config::ConsoleAttachment::Disconnected +variant nvx::host::config::ConsoleAttachment::Listener +struct nvx::host::config::LayeredRoot +field nvx::host::config::LayeredRoot::format +field nvx::host::config::LayeredRoot::gpt_first_partition_bound +field nvx::host::config::LayeredRoot::image +field nvx::host::config::LayeredRoot::overlay +field nvx::host::config::LayeredRoot::upper +enum nvx::host::config::RootConfig +variant nvx::host::config::RootConfig::Blockless +variant nvx::host::config::RootConfig::Layered +field nvx::host::config::RootConfig::Layered::0 +enum nvx::host::config::UpperConfig +const nvx::host::config::UpperConfig::DEFAULT_MAX_MEMORY_PERCENT +variant nvx::host::config::UpperConfig::Ram +field nvx::host::config::UpperConfig::Ram::max_memory_percent +variant nvx::host::config::UpperConfig::Scratch +field nvx::host::config::UpperConfig::Scratch::journal +field nvx::host::config::UpperConfig::Scratch::path +struct nvx::host::config::VmConfig +method nvx::host::config::VmConfig::check +field nvx::host::config::VmConfig::console +field nvx::host::config::VmConfig::filesystem +field nvx::host::config::VmConfig::memory_mib +field nvx::host::config::VmConfig::processors +field nvx::host::config::VmConfig::root +field nvx::host::config::VmConfig::view mod nvx::host::filesystem struct nvx::host::filesystem::Bind field nvx::host::filesystem::Bind::child diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index e5e94305a..d30cf2110 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -226,6 +226,9 @@ field nvx::activation::Upper::Scratch::binding field nvx::activation::Upper::Scratch::journal variant nvx::activation::Upper::Tmpfs const nvx::activation::VERSION +struct nvx::activation::View +field nvx::activation::View::hostname +field nvx::activation::View::machine_id struct nvx::activation::WorkloadIdentity field nvx::activation::WorkloadIdentity::gid field nvx::activation::WorkloadIdentity::uid @@ -672,6 +675,35 @@ fn nvx::host::artifacts::check_manifest fn nvx::host::artifacts::openvmm_executable mod nvx::host::cli (hidden) fn nvx::host::cli::main (hidden) +mod nvx::host::config +enum nvx::host::config::ConsoleAttachment +variant nvx::host::config::ConsoleAttachment::Disconnected +variant nvx::host::config::ConsoleAttachment::Listener +struct nvx::host::config::LayeredRoot +field nvx::host::config::LayeredRoot::format +field nvx::host::config::LayeredRoot::gpt_first_partition_bound +field nvx::host::config::LayeredRoot::image +field nvx::host::config::LayeredRoot::overlay +field nvx::host::config::LayeredRoot::upper +enum nvx::host::config::RootConfig +variant nvx::host::config::RootConfig::Blockless +variant nvx::host::config::RootConfig::Layered +field nvx::host::config::RootConfig::Layered::0 +enum nvx::host::config::UpperConfig +const nvx::host::config::UpperConfig::DEFAULT_MAX_MEMORY_PERCENT +variant nvx::host::config::UpperConfig::Ram +field nvx::host::config::UpperConfig::Ram::max_memory_percent +variant nvx::host::config::UpperConfig::Scratch +field nvx::host::config::UpperConfig::Scratch::journal +field nvx::host::config::UpperConfig::Scratch::path +struct nvx::host::config::VmConfig +method nvx::host::config::VmConfig::check +field nvx::host::config::VmConfig::console +field nvx::host::config::VmConfig::filesystem +field nvx::host::config::VmConfig::memory_mib +field nvx::host::config::VmConfig::processors +field nvx::host::config::VmConfig::root +field nvx::host::config::VmConfig::view mod nvx::host::filesystem struct nvx::host::filesystem::Bind field nvx::host::filesystem::Bind::child diff --git a/nvx-rs/src/activation/mod.rs b/nvx-rs/src/activation/mod.rs index 9a2972203..d66ca0523 100644 --- a/nvx-rs/src/activation/mod.rs +++ b/nvx-rs/src/activation/mod.rs @@ -45,7 +45,8 @@ //! - the [`WorkloadView`]: the hostname, the [`MachineIdMode`], the image's own machine ID or one //! that the guest generates, and the [`WorkloadIdentity`], if any: a UID and a GID, neither 0 //! nor `u32::MAX`, which the guest ignores as it applies the record, since each execution of a -//! clone names its own identity, as CRI containers do; +//! clone names its own identity, as CRI containers do. A VM's configuration names its hostname +//! and machine ID in a [`View`], which leaves the identity to each execution; //! - the root's settings: the [`OverlayMode`], the [`Journal`] policy of a scratch upper, and the //! bound of a GPT disk's first partition; //! - the [`ExpectedResources`]: the CPU count and the memory size that the guest must have, as a @@ -481,6 +482,16 @@ pub struct WorkloadView { pub identity: Option, } +/// The view that a VM's workload has of it: its hostname and its machine ID, without an identity +/// of the executions, which each execution names. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct View { + /// The hostname: 1 to [`MAX_HOSTNAME_BYTES`] bytes, without a NUL. + pub hostname: Vec, + /// The machine ID that the workload sees. + pub machine_id: MachineIdMode, +} + /// The resources that the guest must have, as a clone's repair produces them. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct ExpectedResources { @@ -550,8 +561,7 @@ impl Configuration { { return Err(ActivationError::ProxyEndpoint); } - let hostname = &self.workload.hostname; - if hostname.is_empty() || hostname.len() > MAX_HOSTNAME_BYTES || hostname.contains(&0) { + if !is_hostname(&self.workload.hostname) { return Err(ActivationError::Hostname); } // The kernel reads `u32::MAX` as an unchanged ID, which the guest's entry refuses. @@ -745,6 +755,12 @@ impl fmt::Display for ActivationError { impl std::error::Error for ActivationError {} +/// Returns whether `hostname` is a hostname that a record or a VM's [`View`] holds: 1 to +/// [`MAX_HOSTNAME_BYTES`] bytes without a NUL, since the guest's entry sets any other. +pub(crate) fn is_hostname(hostname: &[u8]) -> bool { + !hostname.is_empty() && hostname.len() <= MAX_HOSTNAME_BYTES && !hostname.contains(&0) +} + /// Returns whether `endpoint` is a proxy endpoint that a record holds: 1 to /// [`MAX_PROXY_ENDPOINT_BYTES`] visible ASCII characters, without an `@`, which would start a /// URL's user information, where credentials go. diff --git a/nvx-rs/src/host/config/mod.rs b/nvx-rs/src/host/config/mod.rs new file mode 100644 index 000000000..d3a04ba59 --- /dev/null +++ b/nvx-rs/src/host/config/mod.rs @@ -0,0 +1,165 @@ +//! The configuration of a VM, as one value that [`VmConfig::check`] checks without reading the +//! host: the VM's processors and memory, its [`RootConfig`], the [`View`] that its workload has +//! of it, the host paths that it exports, and its [`ConsoleAttachment`]. + +use std::ops::RangeInclusive; +use std::path::PathBuf; + +use super::filesystem::{ExportPlan, ReservedTargets}; +use crate::activation::{self, Journal, MAX_HOSTNAME_BYTES, OverlayMode, View}; +use crate::error::{Error, Result}; +use crate::protocol::root::{ImageFormat, RootImage}; + +/// The caps of a RAM upper that [`VmConfig::check`] accepts, in percent of the VM's memory. +const RAM_UPPER_CAPS: RangeInclusive = 1..=90; + +/// The configuration of a VM. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VmConfig { + /// The number of processors: at least 1. + pub processors: u32, + /// The memory, in MiB: at least 1. + pub memory_mib: u64, + /// The root that the guest runs. + pub root: RootConfig, + /// The view that the VM's workload has of it. + pub view: View, + /// The host paths that the VM exports to its guest, if any. + pub filesystem: Option, + /// Where the output of the VM's boot console goes. + pub console: ConsoleAttachment, +} + +/// The root of a VM. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RootConfig { + /// The guest's own system, from its initramfs, without a block device. + Blockless, + /// A read-only image under an upper, which overlayfs combines. + Layered(LayeredRoot), +} + +/// A layered root: a read-only image under an upper. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LayeredRoot { + /// The image, which OpenVMM opens by its path. + pub image: PathBuf, + /// The image's format, and the identity that the guest expects of the image, if any. + pub format: RootImage, + /// The most bytes that the first partition of a GPT disk may hold, if bounded: at least 1. An + /// EROFS image has none. + pub gpt_first_partition_bound: Option, + /// Where the overlay keeps its upper. + pub upper: UpperConfig, + /// The overlay's mode. + pub overlay: OverlayMode, +} + +/// Where a layered root keeps the upper of its overlay. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UpperConfig { + /// A whole-device ext4 filesystem on a scratch image. + Scratch { + /// The scratch image, which OpenVMM opens by its path. + path: PathBuf, + /// Whether the scratch's filesystem may have a journal. + journal: Journal, + }, + /// RAM, up to a cap. + Ram { + /// The most that the upper may hold, in percent of the VM's memory: 1 to 90. + max_memory_percent: u8, + }, +} + +impl UpperConfig { + /// The cap of a RAM upper that a caller sets when it has no other: half of the VM's memory. + pub const DEFAULT_MAX_MEMORY_PERCENT: u8 = 50; +} + +/// Where the output of a VM's boot console goes. The console's device exists either way. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ConsoleAttachment { + /// OpenVMM listens for a client of the console. + Listener, + /// OpenVMM discards the console's output. + Disconnected, +} + +impl VmConfig { + /// Checks the whole configuration without reading the host: it opens no path, and checks no + /// support of the host. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), naming the first + /// refusal in the order of the fields: no processor; no memory; a layered root without an image + /// path, with a bound of an EROFS image's first partition or a bound of 0, with a scratch upper + /// without a path, or with a RAM upper whose cap is not 1 to 90 percent; a hostname that is + /// empty, longer than [`MAX_HOSTNAME_BYTES`], or holds a NUL; and an export that + /// [`ExportPlan::check`] refuses for the root, whose error is the source. + pub fn check(&self) -> Result<()> { + if self.processors == 0 { + return Err(Error::unsupported("a VM needs at least one processor")); + } + if self.memory_mib == 0 { + return Err(Error::unsupported("a VM needs memory")); + } + let reserved = match &self.root { + RootConfig::Blockless => ReservedTargets::BLOCKLESS_ROOT, + RootConfig::Layered(root) => { + root.check()?; + ReservedTargets::CONTAINER_ROOT + } + }; + if !activation::is_hostname(&self.view.hostname) { + return Err(Error::unsupported(format!( + "the hostname is not 1 to {MAX_HOSTNAME_BYTES} bytes without a NUL" + ))); + } + if let Some(plan) = &self.filesystem { + plan.check(reserved).map_err(|error| { + Error::unsupported("the export plan is invalid").with_source(error) + })?; + } + Ok(()) + } +} + +impl LayeredRoot { + /// Checks the root's fields, as [`VmConfig::check`] describes. + fn check(&self) -> Result<()> { + if self.image.as_os_str().is_empty() { + return Err(Error::unsupported("the layered root names no image")); + } + match (self.format.format, self.gpt_first_partition_bound) { + (ImageFormat::Erofs, Some(_)) => { + return Err(Error::unsupported( + "an EROFS image has no first partition to bound, as a GPT disk has", + )); + } + (_, Some(0)) => { + return Err(Error::unsupported( + "the bound of a GPT disk's first partition is 0, but a partition holds at \ + least one block", + )); + } + _ => {} + } + match &self.upper { + UpperConfig::Scratch { path, .. } if path.as_os_str().is_empty() => Err( + Error::unsupported("the scratch upper names no scratch image"), + ), + UpperConfig::Ram { max_memory_percent } + if !RAM_UPPER_CAPS.contains(max_memory_percent) => + { + Err(Error::unsupported(format!( + "the RAM upper's cap of {max_memory_percent}% of the VM's memory is not 1 to \ + 90%" + ))) + } + _ => Ok(()), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/host/config/tests.rs b/nvx-rs/src/host/config/tests.rs new file mode 100644 index 000000000..b92819e19 --- /dev/null +++ b/nvx-rs/src/host/config/tests.rs @@ -0,0 +1,218 @@ +//! Tests of a VM's configuration and of its check, on paths that don't exist, since the check +//! reads nothing on the host. + +use std::error::Error as _; + +use super::*; +use crate::ErrorKind; +use crate::activation::MachineIdMode; +use crate::host::filesystem::{Bind, ExportChild, Owner}; +use crate::uuid::Uuid; + +/// Returns the path of `name` in a directory that doesn't exist. +fn missing(name: &str) -> PathBuf { + std::env::temp_dir().join("nvx-config-missing").join(name) +} + +/// Returns a blockless configuration of one processor and 512 MiB. +fn blockless() -> VmConfig { + VmConfig { + processors: 1, + memory_mib: 512, + root: RootConfig::Blockless, + view: View { + hostname: b"box".to_vec(), + machine_id: MachineIdMode::Image, + }, + filesystem: None, + console: ConsoleAttachment::Listener, + } +} + +/// Returns a layered root of an EROFS image with a RAM upper of the default cap. +fn erofs() -> LayeredRoot { + LayeredRoot { + image: missing("image.erofs"), + format: RootImage::erofs(None), + gpt_first_partition_bound: None, + upper: UpperConfig::Ram { + max_memory_percent: UpperConfig::DEFAULT_MAX_MEMORY_PERCENT, + }, + overlay: OverlayMode::TrustedLowers, + } +} + +/// Returns a configuration of `root`, which is otherwise [`blockless`]'s. +fn layered(root: LayeredRoot) -> VmConfig { + VmConfig { + root: RootConfig::Layered(root), + ..blockless() + } +} + +/// Returns a plan that exports one host directory read-only at `target`. +fn export(target: &str) -> ExportPlan { + ExportPlan { + children: vec![ExportChild { + root: missing("export"), + file: false, + writable: false, + denied: Vec::new(), + denied_identities: Vec::new(), + allowed: Vec::new(), + write: Vec::new(), + }], + binds: vec![Bind { + child: 0, + source: String::new(), + target: target.to_owned(), + read_only: true, + identity: None, + }], + owner: Owner::Vmm, + } +} + +#[test] +fn blockless_and_layered_configurations_pass_with_either_console() { + let gpt = LayeredRoot { + image: missing("image.gpt"), + format: RootImage::gpt(Some(Uuid::from_bytes([7; 16]))), + gpt_first_partition_bound: Some(64 * 1024), + upper: UpperConfig::Scratch { + path: missing("scratch.ext4"), + journal: Journal::Forbidden, + }, + overlay: OverlayMode::UntrustedLowers, + }; + let unbounded = LayeredRoot { + gpt_first_partition_bound: None, + ..gpt.clone() + }; + for config in [ + blockless(), + layered(erofs()), + layered(gpt), + layered(unbounded), + ] { + for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { + let config = VmConfig { + console, + ..config.clone() + }; + config.check().unwrap(); + } + } +} + +#[test] +fn a_ram_upper_holds_1_to_90_percent_of_the_memory_and_half_by_default() { + assert_eq!(UpperConfig::DEFAULT_MAX_MEMORY_PERCENT, 50); + let ram = |max_memory_percent| { + layered(LayeredRoot { + upper: UpperConfig::Ram { max_memory_percent }, + ..erofs() + }) + }; + for cap in [1, UpperConfig::DEFAULT_MAX_MEMORY_PERCENT, 90] { + ram(cap).check().unwrap(); + } + for cap in [0, 91, u8::MAX] { + let error = ram(cap).check().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert!(error.to_string().contains("RAM upper's cap"), "{error}"); + } +} + +#[test] +fn each_refusal_names_what_it_refuses() { + let view = |hostname: &[u8]| VmConfig { + view: View { + hostname: hostname.to_vec(), + machine_id: MachineIdMode::Generated, + }, + ..blockless() + }; + // The longest hostname passes. + view(&[b'h'; MAX_HOSTNAME_BYTES]).check().unwrap(); + let refused = [ + ( + VmConfig { + processors: 0, + ..blockless() + }, + "processor", + ), + ( + VmConfig { + memory_mib: 0, + ..blockless() + }, + "memory", + ), + ( + layered(LayeredRoot { + image: PathBuf::new(), + ..erofs() + }), + "no image", + ), + ( + layered(LayeredRoot { + gpt_first_partition_bound: Some(1), + ..erofs() + }), + "EROFS image", + ), + ( + layered(LayeredRoot { + format: RootImage::gpt(None), + gpt_first_partition_bound: Some(0), + ..erofs() + }), + "first partition is 0", + ), + ( + layered(LayeredRoot { + upper: UpperConfig::Scratch { + path: PathBuf::new(), + journal: Journal::Allowed, + }, + ..erofs() + }), + "no scratch image", + ), + (view(b""), "hostname"), + (view(&[b'h'; MAX_HOSTNAME_BYTES + 1]), "hostname"), + (view(b"a\0b"), "hostname"), + ]; + for (config, refusal) in refused { + let error = config.check().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert!(error.to_string().contains(refusal), "{error}"); + } +} + +#[test] +fn an_export_passes_the_check_of_the_targets_that_its_root_reserves() { + // A blockless root runs the guest's own system from /usr, which a layered root's image holds. + let usr = export("/usr/local/work"); + // A layered root's runtime stages /.nvx-agent, which a blockless root leaves free. + let agent = export("/.nvx-agent/work"); + let with = |config: VmConfig, plan: &ExportPlan| VmConfig { + filesystem: Some(plan.clone()), + ..config + }; + with(layered(erofs()), &usr).check().unwrap(); + with(blockless(), &agent).check().unwrap(); + for (config, target) in [ + (with(blockless(), &usr), "/usr"), + (with(layered(erofs()), &agent), "/.nvx-agent"), + ] { + let error = config.check().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert_eq!(error.to_string(), "the export plan is invalid"); + let source = error.source().expect("the export's error").to_string(); + assert!(source.contains(target), "{source}"); + } +} diff --git a/nvx-rs/src/host/mod.rs b/nvx-rs/src/host/mod.rs index 69e47ff5c..c03af0f9c 100644 --- a/nvx-rs/src/host/mod.rs +++ b/nvx-rs/src/host/mod.rs @@ -6,6 +6,9 @@ //! [`RuntimeIdentity`](runtime::RuntimeIdentity), the hypervisor, which //! [`probe_hypervisor`](runtime::probe_hypervisor) checks, and the //! [`LaunchOptions`](runtime::LaunchOptions). +//! - [`config`] holds the configuration of a VM as one value, a [`VmConfig`](config::VmConfig), +//! which it checks without reading the host: the VM's processors and memory, its root, the view +//! that its workload has of it, its export, and its boot console. //! - [`launch`] builds OpenVMM's command line from a [`LaunchSpec`](launch::LaunchSpec): the //! managed lifecycle, the workload identity, the kernel command line, and the network and //! filesystem options. @@ -70,6 +73,7 @@ pub mod artifacts; #[cfg(feature = "cli")] #[doc(hidden)] pub mod cli; +pub mod config; pub mod filesystem; pub mod instance; pub mod launch; From 3bd7e0d77a88d6fe401125f05dd945406527b85f Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 11:54:55 -0800 Subject: [PATCH 03/29] test-microvm: bound polling over static loopback fixtures The fixture already freezes socket tables and waits for stubbed server output. Observe a failed condition twice instead of spawning 101 awk or grep processes over immutable input. Keep all 13 cases, the 10-second subprocess timeout and production polling unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d81c32c-865b-42d0-8f3e-6881809e0157 --- scripts/test_microvm_tests.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/test_microvm_tests.py b/scripts/test_microvm_tests.py index 5526b590f..aeeb7f3a2 100644 --- a/scripts/test_microvm_tests.py +++ b/scripts/test_microvm_tests.py @@ -5203,6 +5203,9 @@ def _run_host_loopback_allow_script( .replace( "/tmp/nvx-host-loopback-", f"{root.as_posix()}/nvx-host-loopback-" ) + # Fixture inputs never change, so repeated polling only adds + # process-launch overhead, especially with Git's Windows shell. + .replace('[ "$tries" -lt 100 ]', '[ "$tries" -lt 1 ]') # The stubbed servers finish at once; wait for their output # before releasing the guest. .replace("read -r release", "wait; read -r release") From 74b048cab81e7834bf795a731619e636a3dd7f56 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Wed, 4 Nov 2026 00:46:55 -0800 Subject: [PATCH 04/29] nvx-rs: carry the root's settings in its boot tokens, and cap a RAM upper The boot tokens of a layered root gain four optional settings, after the image's and the upper's: nvx_overlay_mode, trusted-lowers or untrusted-lowers; nvx_scratch_journal, allowed or forbidden; nvx_first_partition_bound, a GPT disk's bound in bytes; and nvx_ram_upper_percent, a RAM upper's cap from 1 to 90 percent of the VM's memory. RootLayout names each as an Option that renders no token when it is None, so that the guest keeps its default. RootLayout::parse reads them strictly: each once, with a value in its decimal or named form, only beside an image, and the journal policy only with a scratch upper, the cap with a RAM upper, and the bound with a GPT image. The shared fixture records the new cases, which nvx-rs and the shell init both replay. guest::root's Upper::Tmpfs takes the cap, mounts the tmpfs with size=N% when it has one, and refuses a cap outside 1 to 90 percent before anything changes, since the kernel reads 0 as no cap. NVX's shell init reads the four tokens with the parser's rules and messages, mounts a capped RAM upper with size=N%, and refuses, before it mounts anything, untrusted lowers and a forbidden journal, which it cannot assemble, as it refuses a GPT image. The sandbox plan names none of the settings, so its command lines stay as they were, and an adapter's plan refuses a setting that its command line names, since it reads the root's tokens back. A clone's activation assembles its tmpfs without a cap, since its record names none, and the runtime adapter's worker hands the cap of its boot tokens to its root. BOOT_CONTRACT_VERSION stays 5, since nothing deployed renders the new tokens. The public API snapshots list the new items. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e354d65-494d-4fa9-ae9b-4f2e86c3c0f8 --- .../4.1-guest-filesystem-and-image-model.md | 30 ++- guest/common/nvx-init-agent | 81 +++++- nvx-rs/README.md | 31 ++- nvx-rs/public-api/linux.txt | 11 + nvx-rs/public-api/windows.txt | 8 + nvx-rs/src/guest/clone/activation/apply.rs | 5 +- .../src/guest/clone/activation/apply/tests.rs | 12 +- nvx-rs/src/guest/root.rs | 53 +++- nvx-rs/src/guest/root/tests.rs | 122 ++++++--- nvx-rs/src/host/sandbox/plan.rs | 7 +- nvx-rs/src/host/sandbox/plan/tests.rs | 42 ++- nvx-rs/src/host/sandbox/tests.rs | 4 + nvx-rs/src/host/template/plan/tests.rs | 12 +- nvx-rs/src/protocol/root.rs | 248 ++++++++++++++--- nvx-rs/src/protocol/root/tests.rs | 255 ++++++++++++++++-- nvx-rs/tests/fixtures/root-tokens.json | 43 ++- scripts/test_nvx_tools.py | 132 ++++++++- tests/runtime-adapter/README.md | 8 +- tests/runtime-adapter/src/worker.rs | 14 +- .../src/worker/activation/tests.rs | 4 +- tests/runtime-adapter/tests/guest_session.rs | 10 +- tests/runtime-adapter/tests/layered_launch.rs | 4 + 22 files changed, 972 insertions(+), 164 deletions(-) diff --git a/doc/design/4.1-guest-filesystem-and-image-model.md b/doc/design/4.1-guest-filesystem-and-image-model.md index 4d64c77b5..3a7500bbf 100644 --- a/doc/design/4.1-guest-filesystem-and-image-model.md +++ b/doc/design/4.1-guest-filesystem-and-image-model.md @@ -45,8 +45,9 @@ stored and cached image bytes, because the guest kernel decompresses blocks as it reads them. No FUSE daemon or in-guest fetch service is involved in the root filesystem. -The launcher describes the root to the guest in two boot tokens, whose grammar -one contract module defines, with the host's renderer and a parser for guests: +The launcher describes the root to the guest in two boot tokens, and in up to +four more for its settings, whose grammar one contract module defines, with the +host's renderer and a parser for guests: - `nvx_layer=distro,0xd0003000,FORMAT[:IDENTITY]` names the image on the `distro` block device: its format, `erofs` for an EROFS filesystem or `gpt` @@ -56,13 +57,23 @@ one contract module defines, with the host's renderer and a parser for guests: - `nvx_scratch=0xd0006000,ext4` keeps the overlay's upper on the whole-device ext4 `scratch` disk, and `nvx_overlay_upper=ramfs` keeps it in guest memory, on a tmpfs, with no `scratch` block device. +- `nvx_overlay_mode=trusted-lowers` or `untrusted-lowers` names the overlay's + option set, `nvx_scratch_journal=allowed` or `forbidden` whether the + scratch's ext4 filesystem may have a journal, `nvx_first_partition_bound` + the most bytes of a GPT disk's first partition, and `nvx_ram_upper_percent` + the tmpfs upper's cap, from 1 to 90 percent of the guest's memory. A number + is decimal, without a sign or leading zeros. The guest takes its default for + a setting without a token: trusted lowers, a journal allowed, and the + kernel's default size of a tmpfs. Guests refuse a token without a value, a repeated token, both uppers, an image -without an upper or an upper without an image, a RAM upper beside a `scratch` -block device, and every other form, including the earlier `nvx_layer` form -without a format, rather than probe the devices. The guest agents described -here assemble only an EROFS image, over either upper: they refuse a GPT image -with a message that says so. +without an upper or an upper or a setting without an image, a setting of +another upper or of another format, a RAM upper beside a `scratch` block +device, and every other form, including the earlier `nvx_layer` form without a +format, rather than probe the devices. The guest agents described here +assemble only an EROFS image, over either upper, for trusted lowers, and don't +check a scratch for a journal: they refuse a GPT image, untrusted lowers, and +a forbidden journal with a message that says so. The guest agents assemble the root in this order: @@ -77,8 +88,9 @@ The guest agents assemble the root in this order: the token names one. 4. Mount the upper's file system, and create the overlay's upper and work directories on it: resolve and flush scratch and mount it as ext4 with - `rw,nosuid,nodev`, or mount a tmpfs with `mode=0755,nosuid,nodev` at the - kernel's default size, half of the guest's RAM. + `rw,nosuid,nodev`, or mount a tmpfs with `mode=0755,nosuid,nodev`, and + `size=N%` when `nvx_ram_upper_percent=N` caps it, or else at the kernel's + default size, half of the guest's RAM. 5. Mount the overlay with the `distro` layer as its lower directory, metadata-only copy-up, and extended inode numbers. 6. Verify the workload identity in the assembled root, mount each live share diff --git a/guest/common/nvx-init-agent b/guest/common/nvx-init-agent index dcad77a21..15d6ea49d 100644 --- a/guest/common/nvx-init-agent +++ b/guest/common/nvx-init-agent @@ -212,24 +212,36 @@ read_layer_token() { # Reads the boot tokens of the layered root, with the rules of the host's # renderer, nvx::protocol::root: nvx_layer names the image of the distro block -# device, and nvx_scratch=0xd0006000,ext4 or nvx_overlay_upper=ramfs the upper, -# each once. This init assembles only an EROFS image, so it also refuses a GPT -# image. Sets layer_uuid to the filesystem UUID that the image must have, or to -# nothing if the token names none, and upper to scratch or ram. The parser also -# refuses a RAM upper beside a scratch block device, which this init checks -# against the kernel's devices instead, with refuse_scratch_device. The tests -# replay nvx-rs/tests/fixtures/root-tokens.json through it. +# device, nvx_scratch=0xd0006000,ext4 or nvx_overlay_upper=ramfs the upper, and +# nvx_overlay_mode, nvx_scratch_journal, nvx_first_partition_bound, and +# nvx_ram_upper_percent the root's settings, each once. This init assembles +# only an EROFS image, mounts the overlay only for trusted lowers, and reads +# nothing of a scratch's superblock, so after those rules it refuses a GPT +# image, untrusted lowers, and a forbidden journal too. A bound is checked as +# an unsigned decimal even though it needs a GPT image, which this init cannot +# assemble. Sets layer_uuid to the filesystem UUID that the image must have, or to +# nothing if the token names none, upper to scratch or ram, and ram_percent to +# the RAM upper's cap, or to nothing for the kernel's default size. The parser +# also refuses a RAM upper beside a scratch block device, which this init +# checks against the kernel's devices instead, with refuse_scratch_device. The +# tests replay nvx-rs/tests/fixtures/root-tokens.json through it. read_root_tokens() { layer_seen=false scratch_seen=false ram_seen=false layer_uuid= + # The names of the settings that the command line names, and the values of + # three of them. + settings= + overlay_mode= + scratch_journal= + ram_percent= # Kernel command-line tokens are deliberately split on spaces. # shellcheck disable=SC2086 set -f for token in $cmdline; do case "$token" in - nvx_layer | nvx_scratch | nvx_overlay_upper) + nvx_layer | nvx_scratch | nvx_overlay_upper | nvx_overlay_mode | nvx_scratch_journal | nvx_first_partition_bound | nvx_ram_upper_percent) fatal "the $token kernel parameter has no value" ;; nvx_layer=*) @@ -252,6 +264,38 @@ read_root_tokens() { [ "${token#*=}" = ramfs ] || fatal "the nvx_overlay_upper kernel parameter must be ramfs, not '${token#*=}'" ;; + nvx_overlay_mode=* | nvx_scratch_journal=* | nvx_first_partition_bound=* | nvx_ram_upper_percent=*) + setting=${token%%=*} + case " $settings " in *" $setting "*) fatal "the $setting kernel parameter is repeated" ;; esac + settings="$settings $setting" + case "$token" in + nvx_overlay_mode=trusted-lowers | nvx_overlay_mode=untrusted-lowers) overlay_mode=${token#*=} ;; + nvx_overlay_mode=*) fatal "the $setting kernel parameter must be trusted-lowers or untrusted-lowers, not '${token#*=}'" ;; + nvx_scratch_journal=allowed | nvx_scratch_journal=forbidden) scratch_journal=${token#*=} ;; + nvx_scratch_journal=*) fatal "the $setting kernel parameter must be allowed or forbidden, not '${token#*=}'" ;; + nvx_ram_upper_percent=[1-9] | nvx_ram_upper_percent=[1-8][0-9] | nvx_ram_upper_percent=90) ram_percent=${token#*=} ;; + nvx_ram_upper_percent=*) fatal "the $setting kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '${token#*=}'" ;; + nvx_first_partition_bound= | nvx_first_partition_bound=0* | nvx_first_partition_bound=*[!0-9]*) fatal "the $setting kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '${token#*=}'" ;; + nvx_first_partition_bound=*) + bound_digits=${token#*=} + [ "${#bound_digits}" -le 20 ] || + fatal "the $setting kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '${token#*=}'" + if [ "${#bound_digits}" -eq 20 ]; then + bound_limit=18446744073709551615 + # Compare digits, never the full unsigned value. + while [ -n "$bound_digits" ]; do + bound_digit=${bound_digits%"${bound_digits#?}"} + limit_digit=${bound_limit%"${bound_limit#?}"} + [ "$bound_digit" -le "$limit_digit" ] || + fatal "the $setting kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '${token#*=}'" + [ "$bound_digit" -eq "$limit_digit" ] || break + bound_digits=${bound_digits#?} + bound_limit=${bound_limit#?} + done + fi + ;; + esac + ;; esac done set +f @@ -266,6 +310,15 @@ read_root_tokens() { else fatal "the nvx_layer kernel parameter needs an upper, nvx_scratch or nvx_overlay_upper" fi + [ -z "$scratch_journal" ] || [ "$upper" = scratch ] || + fatal "the nvx_scratch_journal kernel parameter needs a scratch upper, nvx_scratch" + [ -z "$ram_percent" ] || [ "$upper" = ram ] || + fatal "the nvx_ram_upper_percent kernel parameter needs a RAM upper, nvx_overlay_upper" + case " $settings " in *" nvx_first_partition_bound "*) fatal "the nvx_first_partition_bound kernel parameter needs a GPT image" ;; esac + [ "$overlay_mode" != untrusted-lowers ] || + fatal "nvx_overlay_mode=untrusted-lowers names untrusted lowers, which this init cannot assemble: it mounts the overlay only for trusted lowers, with metacopy=on,xino=on" + [ "$scratch_journal" != forbidden ] || + fatal "nvx_scratch_journal=forbidden forbids the scratch a journal, which this init cannot check: it reads nothing of the scratch's superblock" } # Fails if the kernel has a virtio-mmio device at the scratch block device's @@ -288,11 +341,11 @@ refuse_scratch_device() { # Mounts the upper that read_root_tokens read, and creates the overlay's upper # and work directories on it: the ext4 scratch disk at 0xd0006000, or a tmpfs, # with the mode of an ext4 scratch's root rather than tmpfs's world-writable -# default, and with the kernel's default size, half of the guest's RAM, as -# nvx::guest::root mounts them. Sets upper_point to the upper's mount point, -# and upper_name to what messages call it. A tmpfs upper's pages are charged to -# the memory cgroup of the process that writes them, so the workload's writes -# count against its nvx_memory_max. +# default, and with the size of its cap, size=N%, or else the kernel's default +# size, half of the guest's RAM, as nvx::guest::root mounts them. Sets +# upper_point to the upper's mount point, and upper_name to what messages call +# it. A tmpfs upper's pages are charged to the memory cgroup of the process +# that writes them, so the workload's writes count against its nvx_memory_max. mount_upper() { case "$upper" in scratch) @@ -310,7 +363,7 @@ mount_upper() { upper_point=$tmpfs upper_name="the tmpfs upper" mkdir -p "$upper_point" - mount -t tmpfs -o mode=0755,nosuid,nodev tmpfs "$upper_point" || + mount -t tmpfs -o "mode=0755,nosuid,nodev${ram_percent:+,size=$ram_percent%}" tmpfs "$upper_point" || fatal "failed to mount the tmpfs upper" ;; esac diff --git a/nvx-rs/README.md b/nvx-rs/README.md index d4323c888..fe03a0dfe 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -111,7 +111,7 @@ changes only when the module's purpose does. | [`nvx::protocol::cmdline`](#nvxprotocolcmdline) | The kernel command line as Linux and the guest's init read it, with the virtio-mmio devices and the guest's DNS server that it names | | [`nvx::protocol::export`](#nvxprotocolexport) | The boot tokens of the host export, the virtio-fs device from which a guest maps host paths, and the guest's parser of them | | [`nvx::protocol::nvxs`](#nvxprotocolnvxs) | The NVXS session, independent of any application protocol: its records and the state machines of its host and guest roles | -| [`nvx::protocol::root`](#nvxprotocolroot) | The boot tokens that tell the guest which image its layered root assembles and where the root keeps its upper | +| [`nvx::protocol::root`](#nvxprotocolroot) | The boot tokens that tell the guest which image its layered root assembles, where the root keeps its upper, and how the guest assembles them | | [`nvx::protocol::share`](#nvxprotocolshare) | The guest targets that a sandbox's live shares may not take, which the host and the guest both refuse | | [`nvx::protocol::status`](#nvxprotocolstatus) | The exit statuses with which a clone's guest ends its VM once it fails fast, and what each means | | [`nvx::activation`](#nvxactivation) | The activation record of a layered root, which a clone reads in place of its captured configuration, and what Ready reports | @@ -239,16 +239,25 @@ reassembles it. The boot tokens of a layered root, which the host renders and guests parse by the same rules: a `RootLayout` is a `RootImage` on the `distro` block device, an EROFS filesystem or a GPT disk with the `nvx::uuid::Uuid` that the guest expects -of it, if any, over a `RootUpper`, the ext4 scratch disk or RAM. +of it, if any, over a `RootUpper`, the ext4 scratch disk or RAM, with the +settings of the root's assembly that it names: the overlay's `OverlayMode` and +a scratch's `Journal` policy, both of `nvx::activation`, a GPT disk's +first-partition bound, and a RAM upper's cap, from 1 to 90 percent of the VM's +memory. The guest takes its own default for a setting that the layout leaves +out. `RootLayout::tokens` renders `nvx_layer=distro,0xd0003000,FORMAT[:IDENTITY]` and -`nvx_scratch=0xd0006000,ext4` or `nvx_overlay_upper=ramfs`, and +`nvx_scratch=0xd0006000,ext4` or `nvx_overlay_upper=ramfs`, then +`nvx_overlay_mode=trusted-lowers` or `untrusted-lowers`, +`nvx_scratch_journal=allowed` or `forbidden`, `nvx_first_partition_bound=BYTES`, +and `nvx_ram_upper_percent=PERCENT` for the settings that it names, and `RootLayout::parse` reads them from a kernel command line, and refuses -malformed, repeated, and conflicting tokens and the earlier form without a -format, with no fallback that probes the devices. It reads the virtio-mmio -devices of the command line with `nvx::protocol::cmdline`, to refuse a RAM upper -beside one at the scratch base. `tests/fixtures/root-tokens.json` holds its -cases, which the tests of the guest's shell init replay too. +malformed, repeated, and conflicting tokens, a setting of an upper or a format +that the layout lacks, and the earlier form without a format, with no fallback +that probes the devices. It reads the virtio-mmio devices of the command line +with `nvx::protocol::cmdline`, to refuse a RAM upper beside one at the scratch +base. `tests/fixtures/root-tokens.json` holds its cases, which the tests of the +guest's shell init replay too. ### `nvx::protocol::share` @@ -1617,8 +1626,10 @@ option set of the caller's `OverlayMode`, `metacopy=on,xino=on` or, for lowers that may carry untrusted overlay xattrs, `metacopy=off,redirect_dir=nofollow`, with no fallback, whose upper and work directories are on the `Upper`: an ext4 scratch mounted `nosuid,nodev`, whose superblock it checks for a journal that -the caller's `Journal` policy forbids, or a tmpfs mounted `nosuid,nodev` at the -kernel's default size. It refuses overlay options longer than +the caller's `Journal` policy forbids, or a tmpfs mounted `nosuid,nodev` with +`size=N%` for the caller's cap of N percent of the guest's memory, which it +refuses outside 1 to 90 before anything changes, or else at the kernel's +default size. It refuses overlay options longer than `MOUNT_OPTIONS_LIMIT`, the most that a mount passes, which, below `/run/nvx`, fit up to 195 layers of a GPT disk, and an overlay that the kernel's mount table shows read-only or without the mode's options in effect, since the kernel mounts diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 02b5bdeaf..2d8c6aa7a 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -1414,6 +1414,8 @@ field nvx::guest::root::Error::Size::actual (hidden) field nvx::guest::root::Error::Size::device (hidden) field nvx::guest::root::Error::Size::expected (hidden) field nvx::guest::root::Error::Size::role (hidden) +variant nvx::guest::root::Error::TmpfsCap (hidden) +field nvx::guest::root::Error::TmpfsCap::percent (hidden) variant nvx::guest::root::Error::Uuid (hidden) field nvx::guest::root::Error::Uuid::actual (hidden) field nvx::guest::root::Error::Uuid::expected (hidden) @@ -1525,6 +1527,7 @@ variant nvx::guest::root::Upper::Scratch (hidden) field nvx::guest::root::Upper::Scratch::device (hidden) field nvx::guest::root::Upper::Scratch::journal (hidden) variant nvx::guest::root::Upper::Tmpfs (hidden) +field nvx::guest::root::Upper::Tmpfs::max_memory_percent (hidden) method nvx::guest::root::Upper::kind (hidden) enum nvx::guest::root::UpperKind (hidden) variant nvx::guest::root::UpperKind::Scratch (hidden) @@ -3512,12 +3515,15 @@ fn nvx::protocol::nvxs::encode_record fn nvx::protocol::parse_host_mapping_count mod nvx::protocol::root const nvx::protocol::root::DISTRO_MMIO_BASE +const nvx::protocol::root::FIRST_PARTITION_BOUND_TOKEN enum nvx::protocol::root::ImageFormat variant nvx::protocol::root::ImageFormat::Erofs variant nvx::protocol::root::ImageFormat::Gpt method nvx::protocol::root::ImageFormat::as_str method nvx::protocol::root::ImageFormat::from_name const nvx::protocol::root::LAYER_TOKEN +const nvx::protocol::root::OVERLAY_MODE_TOKEN +const nvx::protocol::root::RAM_UPPER_PERCENT_TOKEN const nvx::protocol::root::RAM_UPPER_TOKEN struct nvx::protocol::root::RootImage method nvx::protocol::root::RootImage::erofs @@ -3525,7 +3531,11 @@ field nvx::protocol::root::RootImage::format method nvx::protocol::root::RootImage::gpt field nvx::protocol::root::RootImage::identity struct nvx::protocol::root::RootLayout +field nvx::protocol::root::RootLayout::gpt_first_partition_bound field nvx::protocol::root::RootLayout::image +field nvx::protocol::root::RootLayout::journal +field nvx::protocol::root::RootLayout::max_memory_percent +field nvx::protocol::root::RootLayout::overlay method nvx::protocol::root::RootLayout::parse method nvx::protocol::root::RootLayout::tokens field nvx::protocol::root::RootLayout::upper @@ -3533,6 +3543,7 @@ enum nvx::protocol::root::RootUpper variant nvx::protocol::root::RootUpper::Ram variant nvx::protocol::root::RootUpper::Scratch method nvx::protocol::root::RootUpper::as_str +const nvx::protocol::root::SCRATCH_JOURNAL_TOKEN const nvx::protocol::root::SCRATCH_MMIO_BASE const nvx::protocol::root::SCRATCH_TOKEN mod nvx::protocol::share diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index d30cf2110..f4e187249 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -2437,12 +2437,15 @@ fn nvx::protocol::nvxs::encode_record fn nvx::protocol::parse_host_mapping_count mod nvx::protocol::root const nvx::protocol::root::DISTRO_MMIO_BASE +const nvx::protocol::root::FIRST_PARTITION_BOUND_TOKEN enum nvx::protocol::root::ImageFormat variant nvx::protocol::root::ImageFormat::Erofs variant nvx::protocol::root::ImageFormat::Gpt method nvx::protocol::root::ImageFormat::as_str method nvx::protocol::root::ImageFormat::from_name const nvx::protocol::root::LAYER_TOKEN +const nvx::protocol::root::OVERLAY_MODE_TOKEN +const nvx::protocol::root::RAM_UPPER_PERCENT_TOKEN const nvx::protocol::root::RAM_UPPER_TOKEN struct nvx::protocol::root::RootImage method nvx::protocol::root::RootImage::erofs @@ -2450,7 +2453,11 @@ field nvx::protocol::root::RootImage::format method nvx::protocol::root::RootImage::gpt field nvx::protocol::root::RootImage::identity struct nvx::protocol::root::RootLayout +field nvx::protocol::root::RootLayout::gpt_first_partition_bound field nvx::protocol::root::RootLayout::image +field nvx::protocol::root::RootLayout::journal +field nvx::protocol::root::RootLayout::max_memory_percent +field nvx::protocol::root::RootLayout::overlay method nvx::protocol::root::RootLayout::parse method nvx::protocol::root::RootLayout::tokens field nvx::protocol::root::RootLayout::upper @@ -2458,6 +2465,7 @@ enum nvx::protocol::root::RootUpper variant nvx::protocol::root::RootUpper::Ram variant nvx::protocol::root::RootUpper::Scratch method nvx::protocol::root::RootUpper::as_str +const nvx::protocol::root::SCRATCH_JOURNAL_TOKEN const nvx::protocol::root::SCRATCH_MMIO_BASE const nvx::protocol::root::SCRATCH_TOKEN mod nvx::protocol::share diff --git a/nvx-rs/src/guest/clone/activation/apply.rs b/nvx-rs/src/guest/clone/activation/apply.rs index 96247341b..ba919cea4 100644 --- a/nvx-rs/src/guest/clone/activation/apply.rs +++ b/nvx-rs/src/guest/clone/activation/apply.rs @@ -956,7 +956,10 @@ fn assembly(template: &RootTemplate, configuration: &Configuration) -> Assembly }, }; let upper = match configuration.upper { - activation::Upper::Tmpfs => root::Upper::Tmpfs, + // The record names no cap, so the tmpfs takes the kernel's default size. + activation::Upper::Tmpfs => root::Upper::Tmpfs { + max_memory_percent: None, + }, activation::Upper::Scratch { journal, .. } => root::Upper::Scratch { device: template.scratch, journal: match journal { diff --git a/nvx-rs/src/guest/clone/activation/apply/tests.rs b/nvx-rs/src/guest/clone/activation/apply/tests.rs index b3b02609d..a66827faf 100644 --- a/nvx-rs/src/guest/clone/activation/apply/tests.rs +++ b/nvx-rs/src/guest/clone/activation/apply/tests.rs @@ -632,7 +632,9 @@ fn accepted_assembly() -> Assembly { image: root::Image::Erofs { uuid: Some(IMAGE_UUID), }, - upper: root::Upper::Tmpfs, + upper: root::Upper::Tmpfs { + max_memory_percent: None, + }, overlay: root::OverlayMode::UntrustedLowers, timeout: template.timeout, } @@ -929,8 +931,14 @@ fn assembly_inputs_are_taken_exactly_from_the_record() { (gpt(None), gpt_image(None), true), (gpt(Some(DISK_GUID)), gpt_image(Some(DISK_GUID)), true), ]; + // The record names no cap of a tmpfs, which then takes the kernel's default size. let uppers = [ - (activation::Upper::Tmpfs, root::Upper::Tmpfs), + ( + activation::Upper::Tmpfs, + root::Upper::Tmpfs { + max_memory_percent: None, + }, + ), ( scratch(activation::Journal::Allowed), root::Upper::Scratch { diff --git a/nvx-rs/src/guest/root.rs b/nvx-rs/src/guest/root.rs index 148a7d482..75691b3d0 100644 --- a/nvx-rs/src/guest/root.rs +++ b/nvx-rs/src/guest/root.rs @@ -147,7 +147,7 @@ //! expected: Expected::new().disk_guid("8d2f3a4e-1c5b-4e7a-9f60-2b3c4d5e6f70".parse()?), //! first_partition_bound: 64 * 1024, //! }, -//! upper: Upper::Tmpfs, +//! upper: Upper::Tmpfs { max_memory_percent: Some(50) }, //! overlay: OverlayMode::UntrustedLowers, //! timeout: Duration::from_secs(30), //! })?; @@ -189,6 +189,7 @@ use super::gpt::DeviceError; use super::mappings::{Kind, Mapper, MappingError, TargetRoot, below_its_root}; use super::sys::{self, Errno, FileDescriptor, FileSystem, Mode, MountFlags, OpenFlags}; use crate::gpt::{Expected, GptError, Partition}; +use crate::protocol::root::RAM_UPPER_CAPS; use crate::uuid::Uuid; pub(crate) use self::block::window_directories; @@ -230,7 +231,8 @@ const WORK_DIRECTORY: &str = "work"; /// root directory takes the upper's mode, and a workload that is not root must enter its root. const SCRATCH_DIRECTORY_MODE: u32 = 0o755; /// The tmpfs upper's options: the mode of its root directory, which an ext4 scratch's root has -/// too, rather than tmpfs's world-writable default. Its size is the kernel's default. +/// too, rather than tmpfs's world-writable default. An assembly adds the size of a tmpfs that has +/// a cap, `size=N%`, and the kernel's default size is that of one that has none. const TMPFS_OPTIONS: &[u8] = b"mode=0755"; /// Do not load a GPT layer's journal: even without `needs_recovery`, loading can scan a corrupt /// journal indefinitely. `check_layer` still refuses recovery-required layers. Override the @@ -406,9 +408,13 @@ pub enum Upper { /// Whether the file system may have a journal. journal: Journal, }, - /// A tmpfs at the kernel's default size, which an assembly mounts `nosuid,nodev` at `tmpfs`, - /// for a VM that has no scratch device. - Tmpfs, + /// A tmpfs, which an assembly mounts `nosuid,nodev` at `tmpfs`, for a VM that has no scratch + /// device. + Tmpfs { + /// The tmpfs's cap, in percent of the guest's memory, from 1 to 90, which an assembly + /// mounts it with as `size=N%`, or `None` for the kernel's default size, half of it. + max_memory_percent: Option, + }, } impl Upper { @@ -416,7 +422,7 @@ impl Upper { pub const fn kind(self) -> UpperKind { match self { Self::Scratch { .. } => UpperKind::Scratch, - Self::Tmpfs => UpperKind::Tmpfs, + Self::Tmpfs { .. } => UpperKind::Tmpfs, } } } @@ -472,7 +478,8 @@ impl OverlayMode { /// Assembles the layered root of `assembly`, as the [module](self) describes, and returns the /// root that owns its mounts. It checks everything before it mounts anything. In order, it: /// -/// 1. checks the runtime directory's path; +/// 1. checks the runtime directory's path, and that a tmpfs upper's cap, if any, is from 1 to 90 +/// percent of the guest's memory, since the kernel reads a cap of 0 as none; /// 2. creates the runtime directory, as `mkdir -p` does, and refuses it if a symbolic link leads /// through it; /// 3. creates the mount points of an EROFS image, `layers/distro`, of the upper, `scratch` or @@ -499,7 +506,7 @@ impl OverlayMode { /// 8. mounts the layers: an EROFS image read-only, or a GPT disk's ext4 layers read-only with /// `nosuid` and `nodev`; /// 9. mounts the upper with `nosuid` and `nodev`: the scratch's ext4 file system, or a tmpfs -/// whose root directory has mode 0755; +/// whose root directory has mode 0755, with the size of its cap, if any; /// 10. creates the upper's `upper` and `work` directories unless they exist, with mode 0755 /// whatever the process's umask, since the overlay's root directory takes the upper's mode; /// 11. mounts the overlay at `rootfs`, `nosuid` and `nodev`, with the layers as its lower @@ -611,6 +618,16 @@ fn assemble_with( cleanup: None, }); } + if let Upper::Tmpfs { + max_memory_percent: Some(percent), + } = assembly.upper + && !RAM_UPPER_CAPS.contains(&percent) + { + return Err(AssemblyError { + error: Error::TmpfsCap { percent }, + cleanup: None, + }); + } let mut root = Root { kernel, runtime: assembly.runtime.clone(), @@ -1049,7 +1066,7 @@ impl Root { } Some(device) } - Upper::Tmpfs => None, + Upper::Tmpfs { .. } => None, }; let upper = upper_point.join(UPPER_DIRECTORY); let work = upper_point.join(WORK_DIRECTORY); @@ -1088,13 +1105,20 @@ impl Root { Mount::Scratch } None => { + let mut options = TMPFS_OPTIONS.to_vec(); + if let Upper::Tmpfs { + max_memory_percent: Some(percent), + } = assembly.upper + { + options.extend(format!(",size={percent}%").bytes()); + } self.mount_at( &Mount::Tmpfs, Some(b"tmpfs"), &upper_point, Some(b"tmpfs"), flags, - Some(TMPFS_OPTIONS), + Some(&options), )?; Mount::Tmpfs } @@ -1772,6 +1796,11 @@ pub enum Error { /// The runtime directory. path: PathBuf, }, + /// A tmpfs upper's cap is not 1 to 90 percent of the guest's memory. + TmpfsCap { + /// The cap, in percent of the guest's memory. + percent: u8, + }, /// A mount point or a directory below one could not be created or checked for a mount, or /// the runtime directory could not be locked. MountPoint { @@ -1969,6 +1998,10 @@ impl fmt::Display for Error { `=`, or a symbolic link: {}", path.display() ), + Self::TmpfsCap { percent } => write!( + formatter, + "the tmpfs upper's cap of {percent}% of the guest's memory is not 1 to 90%" + ), Self::MountPoint { path, error } => { write!(formatter, "failed to prepare {}: {error}", path.display()) } diff --git a/nvx-rs/src/guest/root/tests.rs b/nvx-rs/src/guest/root/tests.rs index fbfa2c59d..bc46b7854 100644 --- a/nvx-rs/src/guest/root/tests.rs +++ b/nvx-rs/src/guest/root/tests.rs @@ -224,6 +224,11 @@ fn upper_point(kind: UpperKind) -> &'static str { } } +/// The tmpfs upper without a cap, at the kernel's default size. +const TMPFS: Upper = Upper::Tmpfs { + max_memory_percent: None, +}; + /// Returns the upper on the guest's scratch device, with `journal`. fn scratch_upper(journal: Journal) -> Upper { Upper::Scratch { @@ -945,7 +950,7 @@ fn new_upper_and_work_directories_have_mode_0755_whatever_the_umask() { // So are a tmpfs upper's, which are always new. let guest = Guest::new(); let assembly = Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, ..guest.assembly() }; guest.assemble(&assembly).unwrap().teardown().unwrap(); @@ -958,7 +963,7 @@ fn a_fresh_upper_s_directories_must_be_missing_or_empty() { let mode = |path: &Path| fs::metadata(path).unwrap().mode() & 0o7777; // A scratch or a tmpfs without the directories gets them, as any root's upper does, with no // other call than a cold root's. - for upper in [scratch_upper(Journal::Allowed), Upper::Tmpfs] { + for upper in [scratch_upper(Journal::Allowed), TMPFS] { let guest = Guest::new(); let assembly = Assembly { upper, @@ -1271,8 +1276,8 @@ fn a_runtime_directory_where_a_file_system_is_mounted_is_refused() { // So does an upper that a failed unmount left, whichever upper the next root mounts. for (first, second) in [ - (scratch_upper(Journal::Allowed), Upper::Tmpfs), - (Upper::Tmpfs, scratch_upper(Journal::Allowed)), + (scratch_upper(Journal::Allowed), TMPFS), + (TMPFS, scratch_upper(Journal::Allowed)), ] { let guest = Guest::new(); let left = guest.path(&format!("run/nvx/{}", upper_point(first.kind()))); @@ -1585,8 +1590,25 @@ fn a_gpt_disk_s_layers_are_mounted_under_the_overlay_top_first() { #[test] fn both_overlay_modes_assemble_on_both_uppers_with_their_own_option_sets() { let modes = [OverlayMode::TrustedLowers, OverlayMode::UntrustedLowers]; + // Each upper with the options of its tmpfs, if it is one: a tmpfs with a cap takes its size. + let uppers = [ + (scratch_upper(Journal::Allowed), None), + (TMPFS, Some("mode=0755")), + ( + Upper::Tmpfs { + max_memory_percent: Some(1), + }, + Some("mode=0755,size=1%"), + ), + ( + Upper::Tmpfs { + max_memory_percent: Some(90), + }, + Some("mode=0755,size=90%"), + ), + ]; for gpt in [false, true] { - for upper in [scratch_upper(Journal::Allowed), Upper::Tmpfs] { + for (upper, tmpfs_options) in uppers { for mode in modes { let case = format!("GPT {gpt}, {upper:?}, {mode:?}"); let guest = Guest::new(); @@ -1626,7 +1648,7 @@ fn both_overlay_modes_assemble_on_both_uppers_with_their_own_option_sets() { &format!("{runtime}/tmpfs"), Some("tmpfs"), flags, - Some("mode=0755"), + tmpfs_options, ), ), }; @@ -1676,13 +1698,24 @@ fn a_tmpfs_upper_needs_no_scratch_device() { let guest = Guest::bare(); attach(&guest.base, 0, "vda", 4096); let assembly = Assembly { - upper: Upper::Tmpfs, + upper: Upper::Tmpfs { + max_memory_percent: Some(50), + }, ..guest.assembly() }; let root = guest.assemble(&assembly).unwrap(); // The assembly waited for no other device, and made no mount point for a scratch. assert_eq!(guest.kernel.sleeps(), 0); assert!(!guest.path("run/nvx/scratch").exists()); + // It mounted the tmpfs with its cap. + let tmpfs = mount_call( + Some("tmpfs"), + &guest.text("run/nvx/tmpfs"), + Some("tmpfs"), + MountFlags::MS_NOSUID | MountFlags::MS_NODEV, + Some("mode=0755,size=50%"), + ); + assert!(guest.kernel.calls().contains(&tmpfs)); root.teardown().unwrap(); assert_eq!( guest.kernel.unmounts(), @@ -1693,10 +1726,37 @@ fn a_tmpfs_upper_needs_no_scratch_device() { ); } +#[test] +fn a_tmpfs_upper_refuses_a_cap_that_is_not_1_to_90_percent() { + // The kernel reads a cap of 0 as none, and the boot tokens carry caps from 1 to 90 percent. + let guest = Guest::new(); + for percent in [0, 91, u8::MAX] { + let assembly = Assembly { + upper: Upper::Tmpfs { + max_memory_percent: Some(percent), + }, + ..guest.assembly() + }; + let error = guest.assemble(&assembly).unwrap_err(); + assert!( + matches!(error.error(), Error::TmpfsCap { percent: cap } if *cap == percent), + "{percent}: {error:?}" + ); + assert!(error.cleanup().is_none()); + assert_eq!( + error.to_string(), + format!("the tmpfs upper's cap of {percent}% of the guest's memory is not 1 to 90%") + ); + } + // It refused them before it changed anything. + assert!(guest.kernel.calls().is_empty()); + assert!(!guest.path("run/nvx/layers").exists()); +} + #[test] fn a_refused_option_set_fails_without_a_fallback() { for mode in [OverlayMode::TrustedLowers, OverlayMode::UntrustedLowers] { - for upper in [scratch_upper(Journal::Allowed), Upper::Tmpfs] { + for upper in [scratch_upper(Journal::Allowed), TMPFS] { let guest = Guest::new(); guest.kernel.fail( Operation::Mount, @@ -1752,7 +1812,7 @@ fn an_overlay_that_the_kernel_mounts_without_its_mode_in_effect_is_refused_and_u // Without failing the mount, the kernel drops `metacopy=on` for an upper that can't hold the // overlay's extended attributes. Its mount table then shows no `metacopy`, which is off by // default. - for upper in [scratch_upper(Journal::Allowed), Upper::Tmpfs] { + for upper in [scratch_upper(Journal::Allowed), TMPFS] { let guest = Guest::new(); guest.show_overlay("rw", upper.kind(), "xino=on"); let assembly = Assembly { @@ -1809,7 +1869,7 @@ fn options_that_the_kernel_does_not_show_have_their_defaults() { // The kernel's mount table shows no option at its default, as for each option of the // untrusted lowers under the guest's kernel, whose defaults are off. let untrusted = |guest: &Guest| Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, overlay: OverlayMode::UntrustedLowers, ..guest.assembly() }; @@ -1904,7 +1964,7 @@ fn effective_options_that_cannot_be_read_fail_the_assembly() { } guest.show_overlay("rw", UpperKind::Tmpfs, ""); let assembly = Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, overlay: OverlayMode::UntrustedLowers, ..guest.assembly() }; @@ -1952,7 +2012,7 @@ fn overlay_options_that_a_mount_cannot_pass_are_refused_before_any_mount() { let runtime = guest.path(&format!("run/{}/nvx", [component.as_str(); 6].join("/"))); let assembly = Assembly { runtime: runtime.clone(), - upper: Upper::Tmpfs, + upper: TMPFS, ..guest.assembly() }; let error = guest.assemble(&assembly).unwrap_err(); @@ -2005,7 +2065,7 @@ fn up_to_195_layers_fit_in_the_overlay_s_options_below_run_nvx() { guest.insert(&GptDisk::new(b"configuration", 254)); let mode = OverlayMode::UntrustedLowers; let error = guest - .assemble(&guest.gpt_assembly(Upper::Tmpfs, mode)) + .assemble(&guest.gpt_assembly(TMPFS, mode)) .unwrap_err(); let layers: Vec = (2..=255) .rev() @@ -2164,7 +2224,7 @@ fn a_device_whose_access_mode_is_not_its_role_s_is_refused_before_it_is_flushed( fn a_tmpfs_upper_is_unmounted_last_and_every_failure_reported() { let guest = Guest::new(); let assembly = Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, overlay: OverlayMode::UntrustedLowers, ..guest.assembly() }; @@ -2225,7 +2285,7 @@ fn a_scratch_s_file_system_is_synced_right_before_its_unmount() { // A tmpfs upper has no device to sync to. let guest = Guest::new(); let assembly = Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, ..guest.assembly() }; let root = guest.assemble(&assembly).unwrap(); @@ -2284,7 +2344,7 @@ fn a_gpt_disk_that_differs_from_the_expected_identities_is_refused_before_any_mo // The disk meets every expectation that the caller may set. let guest = Guest::new(); guest.insert(&disk); - let mut assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::UntrustedLowers); + let mut assembly = guest.gpt_assembly(TMPFS, OverlayMode::UntrustedLowers); assembly.image = Image::Gpt { expected: Expected::new() .disk_guid(DISK_GUID) @@ -2337,7 +2397,7 @@ fn a_gpt_disk_that_differs_from_the_expected_identities_is_refused_before_any_mo for (expected, failure) in cases { let guest = Guest::new(); guest.insert(&disk); - let mut assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::UntrustedLowers); + let mut assembly = guest.gpt_assembly(TMPFS, OverlayMode::UntrustedLowers); assembly.image = Image::Gpt { expected, first_partition_bound: FIRST_PARTITION_BOUND, @@ -2364,7 +2424,7 @@ fn a_gpt_disk_that_differs_from_the_expected_identities_is_refused_before_any_mo fn a_gpt_disk_without_a_layer_is_refused_before_any_mount() { let guest = Guest::new(); guest.insert(&GptDisk::new(b"configuration", 0)); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!( @@ -2395,7 +2455,7 @@ fn a_layer_that_is_not_ext4_or_whose_journal_needs_a_recovery_is_refused_before_ disk.layer_mut(2)[1024 + 0x5C] |= 0x04; let guest = Guest::new(); guest.insert(&disk); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); guest.assemble(&assembly).unwrap().teardown().unwrap(); // But not one that needs recovery, despite our `noload` or the superblock's own defaults. @@ -2404,7 +2464,7 @@ fn a_layer_that_is_not_ext4_or_whose_journal_needs_a_recovery_is_refused_before_ layer[1024 + 0x200..][..10].copy_from_slice(b"norecovery"); let guest = Guest::new(); guest.insert(&disk); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!(error.error(), Error::Recovery { number: 3, device } @@ -2426,7 +2486,7 @@ fn a_layer_that_is_not_ext4_or_whose_journal_needs_a_recovery_is_refused_before_ disk.layer_mut(2)[1024 + 0x38] ^= 1; let guest = Guest::new(); guest.insert(&disk); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!(error.error(), Error::NotExt4Layer { number: 2, device } @@ -2447,7 +2507,7 @@ fn a_layer_that_is_not_ext4_or_whose_journal_needs_a_recovery_is_refused_before_ let guest = Guest::new(); fs::write(guest.path("dev/vda"), disk.image()).unwrap(); fs::create_dir(guest.path("dev/vda2")).unwrap(); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!( @@ -2473,7 +2533,7 @@ fn a_missing_partition_fails_at_once_before_any_mount() { let guest = Guest::new(); fs::write(guest.path("dev/vda"), disk.image()).unwrap(); write(&guest.path("dev/vda2"), disk.layer(2)); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!(error.error(), Error::Partition { number: 3, node } @@ -2747,7 +2807,7 @@ fn the_first_partition_is_returned_as_opaque_bytes_within_its_bound() { let guest = Guest::new(); let disk = GptDisk::new(bytes, 1); guest.insert(&disk); - let mut assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let mut assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); // The first partition may be as large as the bound. assembly.image = Image::Gpt { expected: Expected::new(), @@ -2765,7 +2825,7 @@ fn the_first_partition_is_returned_as_opaque_bytes_within_its_bound() { // A first partition larger than the bound is refused before any mount. let guest = Guest::new(); guest.insert(&GptDisk::new(§or, 1)); - let mut assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let mut assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); assembly.image = Image::Gpt { expected: Expected::new(), first_partition_bound: BLOCK - 1, @@ -2799,7 +2859,7 @@ fn the_first_partition_is_returned_as_opaque_bytes_within_its_bound() { fn a_teardown_unmounts_the_layers_top_first_and_reports_every_failure() { let guest = Guest::new(); guest.insert(&GptDisk::new(b"configuration", 3)); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::UntrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::UntrustedLowers); let root = guest.assemble(&assembly).unwrap(); for name in ["run/nvx/layers/p3", "run/nvx/tmpfs"] { guest @@ -2845,7 +2905,7 @@ fn a_teardown_unmounts_the_layers_top_first_and_reports_every_failure() { guest.path("run/nvx/layers/p3"), Errno::EINVAL, ); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::UntrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::UntrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!( @@ -2871,12 +2931,12 @@ fn a_layer_that_a_failed_unmount_left_keeps_the_runtime_directory_occupied() { guest .kernel .fail(Operation::Unmount, top.clone(), Errno::EBUSY); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); guest.assemble(&assembly).unwrap().teardown().unwrap_err(); guest.insert(&GptDisk::new(b"configuration", 1)); let (mounts, calls) = (guest.kernel.mounts().len(), guest.kernel.calls()); let erofs = Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, ..guest.assembly() }; for assembly in [erofs, assembly] { @@ -2896,13 +2956,13 @@ fn a_layer_that_a_failed_unmount_left_keeps_the_runtime_directory_occupied() { .kernel .fail(Operation::Unmount, distro.clone(), Errno::EBUSY); let assembly = Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, ..guest.assembly() }; guest.assemble(&assembly).unwrap().teardown().unwrap_err(); guest.insert(&GptDisk::new(b"configuration", 1)); let (mounts, calls) = (guest.kernel.mounts().len(), guest.kernel.calls()); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!(error.error(), Error::Occupied { path } if *path == distro), diff --git a/nvx-rs/src/host/sandbox/plan.rs b/nvx-rs/src/host/sandbox/plan.rs index d7f96196f..6e7ab5cd7 100644 --- a/nvx-rs/src/host/sandbox/plan.rs +++ b/nvx-rs/src/host/sandbox/plan.rs @@ -495,11 +495,16 @@ impl SandboxPlan { self.network.as_ref().and_then(SandboxNetwork::proxy_url) } - /// Returns the layout of the root that the plan boots: its image and its upper. + /// Returns the layout of the root that the plan boots: its image and its upper. It names none + /// of the root's settings, so the guest assembles the root with its own defaults. pub fn layout(&self) -> RootLayout { RootLayout { image: self.distro.image, upper: self.upper.kind(), + overlay: None, + journal: None, + gpt_first_partition_bound: None, + max_memory_percent: None, } } diff --git a/nvx-rs/src/host/sandbox/plan/tests.rs b/nvx-rs/src/host/sandbox/plan/tests.rs index da35e54d6..3e1d16a82 100644 --- a/nvx-rs/src/host/sandbox/plan/tests.rs +++ b/nvx-rs/src/host/sandbox/plan/tests.rs @@ -172,11 +172,16 @@ fn every_format_and_upper_plans_and_renders() { for (image, upper, tokens) in cases { let (image, upper) = (images()[image], uppers()[upper].clone()); let plan = plan(image, upper.clone(), settings.clone()); + // The plan names none of the root's settings. assert_eq!( plan.layout(), RootLayout { image, - upper: upper.kind() + upper: upper.kind(), + overlay: None, + journal: None, + gpt_first_partition_bound: None, + max_memory_percent: None, } ); assert_eq!( @@ -1367,15 +1372,12 @@ fn adapter_plans_render_their_root_and_devices_alone() { let plan = adapter(image, upper.clone()); assert_eq!(plan.settings(), None); assert_eq!(plan.export(), None); - let [layer, upper_token] = plan.layout().tokens(); + let tokens = plan.layout().tokens().join(" "); assert_eq!( plan.kernel_command_line(user).unwrap(), - format!("{user} {layer} {upper_token}") - ); - assert_eq!( - plan.kernel_command_line(" \t").unwrap(), - format!("{layer} {upper_token}") + format!("{user} {tokens}") ); + assert_eq!(plan.kernel_command_line(" \t").unwrap(), tokens); // No workload identity and no shares: the block devices alone. let mut expected = vec![ "--machine".to_owned(), @@ -1467,6 +1469,32 @@ fn adapter_plans_refuse_what_their_root_and_openvmm_own() { assert_eq!(plan.clone().with_export(None).unwrap(), plan); } +#[test] +fn adapter_plans_refuse_the_settings_of_their_root_that_they_do_not_name() { + // An adapter's plan names none of its root's settings, and the guest reads a setting from the + // command line as the plan reads it back, so the plan refuses a setting that the adapter's + // command line names. + let ram = adapter(images()[3], SandboxUpper::RAM); + let scratch = adapter(images()[1], SandboxUpper::scratch("scratch.ext4")); + let hides = "the kernel command line of an adapter's plan hides the boot tokens of its root"; + for (plan, user, refusal) in [ + (&ram, "nvx_overlay_mode=untrusted-lowers", hides), + (&ram, "nvx_ram_upper_percent=5", hides), + (&ram, "x=\"y nvx_ram_upper_percent=5 z\"", hides), + (&ram, "nvx_first_partition_bound=4096", hides), + (&scratch, "nvx_scratch_journal=forbidden", hides), + ( + &scratch, + "nvx_ram_upper_percent=0", + "the kernel command line of an adapter's plan conflicts with the boot tokens of its \ + root: the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to \ + 90, without a sign or leading zeros, not '0'", + ), + ] { + assert_eq!(message(plan.kernel_command_line(user)), refusal, "{user}"); + } +} + #[test] fn plans_refuse_the_tokens_that_the_guest_init_reads_in_quoted_values() { // The kernel reads `x` with a quoted value, but the guest's init splits the command line at diff --git a/nvx-rs/src/host/sandbox/tests.rs b/nvx-rs/src/host/sandbox/tests.rs index 06255e714..cc4a999df 100644 --- a/nvx-rs/src/host/sandbox/tests.rs +++ b/nvx-rs/src/host/sandbox/tests.rs @@ -166,6 +166,10 @@ fn launch_contract_builds_agent_command_line() { RootLayout { image: RootImage::erofs(Uuid::parse("11111111-1111-1111-1111-111111111111")), upper: RootUpper::Scratch, + overlay: None, + journal: None, + gpt_first_partition_bound: None, + max_memory_percent: None, } ); assert_eq!( diff --git a/nvx-rs/src/host/template/plan/tests.rs b/nvx-rs/src/host/template/plan/tests.rs index f0cc4ef5f..3144eb883 100644 --- a/nvx-rs/src/host/template/plan/tests.rs +++ b/nvx-rs/src/host/template/plan/tests.rs @@ -547,14 +547,20 @@ fn openvmms_platform_allowlist_admits_its_tokens_and_refuses_tenant_data() { } // The tokens of a root, a workload, a share, a listener, and anything else that a tenant's // sandbox or another tier sets. - let [layer, upper] = adapter(None).layout().tokens(); - let [_, ram] = adapter_with(gpt(), SandboxUpper::RAM, None) + let [layer, upper]: [String; 2] = adapter(None).layout().tokens().try_into().unwrap(); + let [_, ram]: [String; 2] = adapter_with(gpt(), SandboxUpper::RAM, None) .layout() - .tokens(); + .tokens() + .try_into() + .unwrap(); for token in [ layer.as_str(), upper.as_str(), ram.as_str(), + "nvx_overlay_mode=untrusted-lowers", + "nvx_scratch_journal=forbidden", + "nvx_first_partition_bound=4096", + "nvx_ram_upper_percent=50", "nvx_hostname=tenant-a", "hostname=tenant-a", "nvx_entrypoint=/bin/sh", diff --git a/nvx-rs/src/protocol/root.rs b/nvx-rs/src/protocol/root.rs index 88ced8b88..a02f976b9 100644 --- a/nvx-rs/src/protocol/root.rs +++ b/nvx-rs/src/protocol/root.rs @@ -1,5 +1,6 @@ //! The boot tokens of a layered root: the kernel command-line tokens through which the host tells -//! the guest which image its layered root assembles, and where the root keeps its upper. +//! the guest which image its layered root assembles, where the root keeps its upper, and how the +//! guest assembles them. //! //! A layered root overlays one read-only image, on the `distro` block device, with a writable //! upper. Two tokens describe it, which [`RootLayout::tokens`] renders: @@ -15,20 +16,38 @@ //! [`SCRATCH_MMIO_BASE`], which the microVM fixes for their roles, and identities take the //! canonical text of a [`Uuid`]. //! +//! Four more tokens each name a setting of the root, after those two and in this order, when the +//! layout names it. Without one, the guest assembles the root with its own default, as it does for +//! a command line that predates them: +//! +//! - `nvx_overlay_mode=trusted-lowers` or `nvx_overlay_mode=untrusted-lowers` names the overlay's +//! [`OverlayMode`]; the guest's default trusts the lowers. +//! - `nvx_scratch_journal=allowed` or `nvx_scratch_journal=forbidden` names whether the ext4 +//! filesystem of a scratch upper may have a journal, its [`Journal`] policy; the guest's default +//! allows one. +//! - `nvx_first_partition_bound=BYTES` bounds the bytes of a GPT disk's first partition. +//! - `nvx_ram_upper_percent=PERCENT` caps a RAM upper at PERCENT percent of the VM's memory, from 1 +//! to 90; the guest's default is the kernel's default size of a tmpfs, half of its memory. +//! +//! A number is decimal, at least 1, without a sign or leading zeros. The journal policy needs a +//! scratch upper, the cap a RAM upper, and the bound a GPT disk. +//! //! Guests read the tokens with [`RootLayout::parse`], which accepts exactly these forms and //! refuses every other, with no fallback that probes the image or the devices. NVX's shell init -//! reads them with the same rules, and refuses the GPT image, which it cannot assemble. It finds a -//! scratch block device beside a RAM upper among the devices that the kernel added, rather than -//! on the command line. +//! reads them with the same rules, and refuses the GPT image, the untrusted lowers, and a +//! forbidden journal, which it cannot assemble. It finds a scratch block device beside a RAM upper +//! among the devices that the kernel added, rather than on the command line. //! //! The tokens belong to the boot contract that //! [`BOOT_CONTRACT_VERSION`](crate::contract::BOOT_CONTRACT_VERSION) versions, so a change to //! them changes that version too. use std::fmt; +use std::ops::RangeInclusive; use super::cmdline::{self, Token}; use super::{ProtocolError, violation}; +use crate::activation::{Journal, OverlayMode}; use crate::uuid::Uuid; /// The MMIO base of the `distro` block device, which holds a layered root's image. @@ -47,18 +66,58 @@ pub const SCRATCH_TOKEN: &str = "nvx_scratch="; /// The token that keeps a layered root's upper in RAM. pub const RAM_UPPER_TOKEN: &str = "nvx_overlay_upper=ramfs"; +/// Prefix of the token that names the overlay's mode, `nvx_overlay_mode=`. +pub const OVERLAY_MODE_TOKEN: &str = "nvx_overlay_mode="; + +/// Prefix of the token that names whether a scratch upper may have a journal, +/// `nvx_scratch_journal=`. +pub const SCRATCH_JOURNAL_TOKEN: &str = "nvx_scratch_journal="; + +/// Prefix of the token that bounds the bytes of a GPT disk's first partition, +/// `nvx_first_partition_bound=`. +pub const FIRST_PARTITION_BOUND_TOKEN: &str = "nvx_first_partition_bound="; + +/// Prefix of the token that caps a RAM upper, in percent of the VM's memory, +/// `nvx_ram_upper_percent=`. +pub const RAM_UPPER_PERCENT_TOKEN: &str = "nvx_ram_upper_percent="; + +/// The caps of a RAM upper that the tokens carry, in percent of the VM's memory. +pub(crate) const RAM_UPPER_CAPS: RangeInclusive = 1..=90; + /// The name of the kernel parameter that names the image. const LAYER: &str = "nvx_layer"; /// The name of the kernel parameter that keeps the upper on the scratch block device. const SCRATCH: &str = "nvx_scratch"; /// The name of the kernel parameter that keeps the upper in RAM. const OVERLAY_UPPER: &str = "nvx_overlay_upper"; +/// The name of the kernel parameter that names the overlay's mode. +const OVERLAY_MODE: &str = "nvx_overlay_mode"; +/// The name of the kernel parameter that names the scratch's journal policy. +const SCRATCH_JOURNAL: &str = "nvx_scratch_journal"; +/// The name of the kernel parameter that bounds a GPT disk's first partition. +const FIRST_PARTITION_BOUND: &str = "nvx_first_partition_bound"; +/// The name of the kernel parameter that caps a RAM upper. +const RAM_UPPER_PERCENT: &str = "nvx_ram_upper_percent"; +/// The names of the kernel parameters of the boot tokens. +const PARAMETERS: [&str; 7] = [ + LAYER, + SCRATCH, + OVERLAY_UPPER, + OVERLAY_MODE, + SCRATCH_JOURNAL, + FIRST_PARTITION_BOUND, + RAM_UPPER_PERCENT, +]; /// The value of the kernel parameter that keeps the upper in RAM. const RAM_UPPER: &str = "ramfs"; /// The role of the image's block device. const DISTRO_ROLE: &str = "distro"; /// The filesystem of the scratch block device. const SCRATCH_FILESYSTEM: &str = "ext4"; +/// Every overlay mode. +const OVERLAY_MODES: [OverlayMode; 2] = [OverlayMode::TrustedLowers, OverlayMode::UntrustedLowers]; +/// Every journal policy. +const JOURNALS: [Journal; 2] = [Journal::Allowed, Journal::Forbidden]; /// The format of a layered root's image, which its token names. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -158,20 +217,32 @@ impl fmt::Display for RootUpper { } } -/// The layout of a layered root: its image, on the `distro` block device, and its upper. +/// The layout of a layered root: its image, on the `distro` block device, its upper, and the +/// settings of its assembly that it names. A setting that it leaves `None` has no token, and the +/// guest assembles the root with its own default for it. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct RootLayout { /// The read-only image. pub image: RootImage, /// The upper. pub upper: RootUpper, + /// The overlay's mode. + pub overlay: Option, + /// Whether the ext4 filesystem of a [scratch](RootUpper::Scratch) upper may have a journal. + pub journal: Option, + /// The bound of the bytes of a [GPT](ImageFormat::Gpt) disk's first partition, at least 1. + pub gpt_first_partition_bound: Option, + /// The cap of a [RAM](RootUpper::Ram) upper, in percent of the VM's memory, from 1 to 90. + pub max_memory_percent: Option, } impl RootLayout { /// Returns the layout's boot tokens: the image's, `nvx_layer=distro,0xd0003000,FORMAT`, with /// `:IDENTITY` when the image has an identity, then the upper's, `nvx_scratch=0xd0006000,ext4` - /// or `nvx_overlay_upper=ramfs`. - pub fn tokens(&self) -> [String; 2] { + /// or `nvx_overlay_upper=ramfs`, then the token of each setting that the layout names, in the + /// order of the [module](self). [`parse`](Self::parse) refuses the tokens of a setting that + /// breaks its rules. + pub fn tokens(&self) -> Vec { let RootImage { format, identity } = self.image; let layer = format!("{LAYER_TOKEN}{DISTRO_ROLE},{DISTRO_MMIO_BASE:#x},{format}"); let layer = match identity { @@ -184,7 +255,19 @@ impl RootLayout { } RootUpper::Ram => RAM_UPPER_TOKEN.to_owned(), }; - [layer, upper] + let settings = [ + self.overlay + .map(|mode| format!("{OVERLAY_MODE_TOKEN}{}", overlay_name(mode))), + self.journal + .map(|journal| format!("{SCRATCH_JOURNAL_TOKEN}{}", journal_name(journal))), + self.gpt_first_partition_bound + .map(|bound| format!("{FIRST_PARTITION_BOUND_TOKEN}{bound}")), + self.max_memory_percent + .map(|percent| format!("{RAM_UPPER_PERCENT_TOKEN}{percent}")), + ]; + let mut tokens = vec![layer, upper]; + tokens.extend(settings.into_iter().flatten()); + tokens } /// Reads the layout of a layered root from the boot tokens of a kernel command line, such as @@ -194,13 +277,14 @@ impl RootLayout { /// The command line splits at spaces, tabs, and newlines, without quoting, as the guest's init /// splits it, with [`cmdline::init_tokens`], and its tokens may come in any order. Each boot /// token must take one of the forms of the [module](self), with a value and at most once. The - /// image needs one upper, and each upper the image. A RAM upper also refuses a scratch block - /// device: the kernel adds a microVM's virtio-mmio devices from the - /// `virtio_mmio.device=SIZE@BASE:IRQ` parameters that the VMM appends, and none may sit at - /// [`SCRATCH_MMIO_BASE`]. The parser reads those parameters as the kernel does, with - /// [`cmdline::virtio_mmio_devices`], since the kernel splits its command line at more - /// whitespace than init, honors double quotes, and stops at `--`. Other tokens are ignored, as - /// are the devices that the layout uses, which the guest resolves itself. + /// image needs one upper, each upper and setting the image, and each setting the upper or the + /// format that it sets. A RAM upper also refuses a scratch block device: the kernel adds a + /// microVM's virtio-mmio devices from the `virtio_mmio.device=SIZE@BASE:IRQ` parameters that + /// the VMM appends, and none may sit at [`SCRATCH_MMIO_BASE`]. The parser reads those + /// parameters as the kernel does, with [`cmdline::virtio_mmio_devices`], since the kernel + /// splits its command line at more whitespace than init, honors double quotes, and stops at + /// `--`. Other tokens are ignored, as are the devices that the layout uses, which the guest + /// resolves itself. /// /// Fails with a [`ProtocolError`] that names the first token that breaks these rules, or the /// tokens that conflict. @@ -208,8 +292,14 @@ impl RootLayout { let mut image = None; let mut scratch = false; let mut ram = false; + let mut overlay = None; + let mut journal = None; + let mut gpt_first_partition_bound = None; + let mut max_memory_percent = None; + // The parameters that the command line names, in its order. + let mut seen = Vec::new(); for Token { name, value } in cmdline::init_tokens(command_line) { - let Some(parameter) = [LAYER, SCRATCH, OVERLAY_UPPER] + let Some(parameter) = PARAMETERS .into_iter() .find(|parameter| parameter.as_bytes() == name) else { @@ -220,16 +310,12 @@ impl RootLayout { "the {parameter} kernel parameter has no value" ))); }; - let seen = match parameter { - LAYER => image.is_some(), - SCRATCH => scratch, - _ => ram, - }; - if seen { + if seen.contains(¶meter) { return Err(violation(format!( "the {parameter} kernel parameter is repeated" ))); } + seen.push(parameter); match parameter { LAYER => image = Some(parse_image(value)?), SCRATCH => { @@ -242,7 +328,7 @@ impl RootLayout { } scratch = true; } - _ => { + OVERLAY_UPPER => { if value != RAM_UPPER.as_bytes() { return Err(violation(format!( "the {OVERLAY_UPPER} kernel parameter must be {RAM_UPPER}, not '{}'", @@ -251,6 +337,14 @@ impl RootLayout { } ram = true; } + OVERLAY_MODE => { + overlay = Some(named(parameter, value, OVERLAY_MODES, overlay_name)?) + } + SCRATCH_JOURNAL => journal = Some(named(parameter, value, JOURNALS, journal_name)?), + FIRST_PARTITION_BOUND => { + gpt_first_partition_bound = Some(number(parameter, value, 1..=u64::MAX)?); + } + _ => max_memory_percent = Some(number(parameter, value, RAM_UPPER_CAPS)?), } } let upper = match (scratch, ram) { @@ -270,25 +364,105 @@ impl RootLayout { (false, true) => Some(RootUpper::Ram), (false, false) => None, }; - match (image, upper) { - (Some(image), Some(upper)) => Ok(Some(Self { image, upper })), - (None, None) => Ok(None), - (Some(_), None) => Err(violation(format!( - "the {LAYER} kernel parameter needs an upper, {SCRATCH} or {OVERLAY_UPPER}" - ))), - (None, Some(upper)) => { - let parameter = match upper { - RootUpper::Scratch => SCRATCH, - RootUpper::Ram => OVERLAY_UPPER, - }; - Err(violation(format!( + // Without an image, the parameters that the command line names are those of an upper or + // of settings. + let Some(image) = image else { + return match seen.first() { + Some(parameter) => Err(violation(format!( "the {parameter} kernel parameter needs an image, which {LAYER} names" - ))) - } + ))), + None => Ok(None), + }; + }; + let Some(upper) = upper else { + return Err(violation(format!( + "the {LAYER} kernel parameter needs an upper, {SCRATCH} or {OVERLAY_UPPER}" + ))); + }; + let needs = if journal.is_some() && upper != RootUpper::Scratch { + Some((SCRATCH_JOURNAL, "a scratch upper, nvx_scratch")) + } else if max_memory_percent.is_some() && upper != RootUpper::Ram { + Some((RAM_UPPER_PERCENT, "a RAM upper, nvx_overlay_upper")) + } else if gpt_first_partition_bound.is_some() && image.format != ImageFormat::Gpt { + Some((FIRST_PARTITION_BOUND, "a GPT image")) + } else { + None + }; + if let Some((parameter, needs)) = needs { + return Err(violation(format!( + "the {parameter} kernel parameter needs {needs}" + ))); } + Ok(Some(Self { + image, + upper, + overlay, + journal, + gpt_first_partition_bound, + max_memory_percent, + })) + } +} + +/// Returns the name of `mode` in the overlay mode's token. +const fn overlay_name(mode: OverlayMode) -> &'static str { + match mode { + OverlayMode::TrustedLowers => "trusted-lowers", + OverlayMode::UntrustedLowers => "untrusted-lowers", } } +/// Returns the name of `journal` in the journal policy's token. +const fn journal_name(journal: Journal) -> &'static str { + match journal { + Journal::Allowed => "allowed", + Journal::Forbidden => "forbidden", + } +} + +/// Parses the value of the token of `parameter`, a setting of which `name` names each of +/// `settings`. +fn named( + parameter: &str, + value: &[u8], + settings: [T; 2], + name: fn(T) -> &'static str, +) -> Result { + let [first, second] = settings.map(name); + settings + .into_iter() + .find(|&setting| name(setting).as_bytes() == value) + .ok_or_else(|| { + violation(format!( + "the {parameter} kernel parameter must be {first} or {second}, not '{}'", + value.escape_ascii() + )) + }) +} + +/// Parses the value of the token of `parameter`, a decimal number in `range`, without a sign or +/// leading zeros. +fn number(parameter: &str, value: &[u8], range: RangeInclusive) -> Result +where + T: std::str::FromStr + PartialOrd + fmt::Display, +{ + // `str::parse` takes a leading `+` too. + std::str::from_utf8(value) + .ok() + .filter(|text| !text.starts_with(['0', '+'])) + .and_then(|text| text.parse().ok()) + .filter(|number| range.contains(number)) + .ok_or_else(|| { + violation(format!( + "the {parameter} kernel parameter must be a decimal number from {} to {}, \ + without a sign or leading zeros, not '{}'", + range.start(), + range.end(), + value.escape_ascii() + )) + }) +} + /// Parses the value of the image's token, `distro,0xd0003000,FORMAT[:IDENTITY]`. fn parse_image(value: &[u8]) -> Result { let fields: Vec<&[u8]> = value.split(|&byte| byte == b',').collect(); diff --git a/nvx-rs/src/protocol/root/tests.rs b/nvx-rs/src/protocol/root/tests.rs index 2af9335b8..241424766 100644 --- a/nvx-rs/src/protocol/root/tests.rs +++ b/nvx-rs/src/protocol/root/tests.rs @@ -14,23 +14,49 @@ const EROFS_UUID: &str = "0123abcd-4567-89ef-0123-456789abcdef"; /// The disk GUID of the tests' GPT images. const GPT_GUID: &str = "fedcba98-7654-3210-fedc-ba9876543210"; +/// The keys of a layout of the fixture. +const LAYOUT_KEYS: [&str; 7] = [ + "format", + "identity", + "upper", + "overlay", + "journal", + "gpt_first_partition_bound", + "max_memory_percent", +]; + fn uuid(text: &str) -> Uuid { Uuid::parse(text).unwrap() } +/// Returns the layout of `image` over `upper`, which names no setting. fn layout(image: RootImage, upper: RootUpper) -> RootLayout { - RootLayout { image, upper } + RootLayout { + image, + upper, + overlay: None, + journal: None, + gpt_first_partition_bound: None, + max_memory_percent: None, + } } fn parse(command_line: &str) -> Result, String> { RootLayout::parse(command_line.as_bytes()).map_err(ProtocolError::into_message) } -/// Returns the layout of a case of the fixture, or `None` for its `null`. +/// Returns the layout of a case of the fixture, or `None` for its `null`. A layout that leaves a +/// setting out names none. fn recorded_layout(value: &Value) -> Option { if value.is_null() { return None; } + for key in value.as_object().unwrap().keys() { + assert!( + LAYOUT_KEYS.contains(&key.as_str()), + "the fixture names the unknown {key}" + ); + } let format = ImageFormat::from_name(value["format"].as_str().unwrap()).unwrap(); let identity = value["identity"].as_str().map(uuid); let upper = match value["upper"].as_str().unwrap() { @@ -38,7 +64,25 @@ fn recorded_layout(value: &Value) -> Option { "ram" => RootUpper::Ram, other => panic!("the fixture names the unknown upper {other}"), }; - Some(layout(RootImage { format, identity }, upper)) + let overlay = value["overlay"].as_str().map(|mode| match mode { + "trusted-lowers" => OverlayMode::TrustedLowers, + "untrusted-lowers" => OverlayMode::UntrustedLowers, + other => panic!("the fixture names the unknown overlay mode {other}"), + }); + let journal = value["journal"].as_str().map(|journal| match journal { + "allowed" => Journal::Allowed, + "forbidden" => Journal::Forbidden, + other => panic!("the fixture names the unknown journal policy {other}"), + }); + Some(RootLayout { + overlay, + journal, + gpt_first_partition_bound: value["gpt_first_partition_bound"].as_u64(), + max_memory_percent: value["max_memory_percent"] + .as_u64() + .map(|percent| u8::try_from(percent).unwrap()), + ..layout(RootImage { format, identity }, upper) + }) } #[test] @@ -48,9 +92,26 @@ fn wire_values_keep_their_literals() { assert_eq!(LAYER_TOKEN, "nvx_layer="); assert_eq!(SCRATCH_TOKEN, "nvx_scratch="); assert_eq!(RAM_UPPER_TOKEN, "nvx_overlay_upper=ramfs"); + assert_eq!(OVERLAY_MODE_TOKEN, "nvx_overlay_mode="); + assert_eq!(SCRATCH_JOURNAL_TOKEN, "nvx_scratch_journal="); + assert_eq!(FIRST_PARTITION_BOUND_TOKEN, "nvx_first_partition_bound="); + assert_eq!(RAM_UPPER_PERCENT_TOKEN, "nvx_ram_upper_percent="); assert_eq!(format!("{LAYER}="), LAYER_TOKEN); assert_eq!(format!("{SCRATCH}="), SCRATCH_TOKEN); assert_eq!(format!("{OVERLAY_UPPER}={RAM_UPPER}"), RAM_UPPER_TOKEN); + assert_eq!(format!("{OVERLAY_MODE}="), OVERLAY_MODE_TOKEN); + assert_eq!(format!("{SCRATCH_JOURNAL}="), SCRATCH_JOURNAL_TOKEN); + assert_eq!( + format!("{FIRST_PARTITION_BOUND}="), + FIRST_PARTITION_BOUND_TOKEN + ); + assert_eq!(format!("{RAM_UPPER_PERCENT}="), RAM_UPPER_PERCENT_TOKEN); + assert_eq!( + OVERLAY_MODES.map(overlay_name), + ["trusted-lowers", "untrusted-lowers"] + ); + assert_eq!(JOURNALS.map(journal_name), ["allowed", "forbidden"]); + assert_eq!(RAM_UPPER_CAPS, 1..=90); assert_eq!(ImageFormat::Erofs.as_str(), "erofs"); assert_eq!(ImageFormat::Gpt.to_string(), "gpt"); assert_eq!(RootUpper::Scratch.as_str(), "scratch"); @@ -110,17 +171,89 @@ fn every_layout_renders_its_tokens_and_reads_them_back() { ram, ), ]; - for (expected, layer, upper) in cases { - assert_eq!(expected.tokens(), [layer.clone(), upper.to_owned()]); - // The guest reads the tokens in either order, among others. - for command_line in [ - format!("{layer} {upper}"), - format!("{upper} {layer}"), - format!("quiet nvx_sandbox=1 {layer}\t{upper}\nnvx_entrypoint=/bin/sh"), - ] { - assert_eq!(parse(&command_line), Ok(Some(expected)), "{command_line}"); + // Each setting that a layout may name, or none, with its token: the overlay's mode, a + // scratch's journal policy, a RAM upper's cap, and a GPT disk's bound. + let overlays = [ + (None, None), + ( + Some(OverlayMode::TrustedLowers), + Some("nvx_overlay_mode=trusted-lowers"), + ), + ( + Some(OverlayMode::UntrustedLowers), + Some("nvx_overlay_mode=untrusted-lowers"), + ), + ]; + let journals = [ + (None, None), + (Some(Journal::Allowed), Some("nvx_scratch_journal=allowed")), + ( + Some(Journal::Forbidden), + Some("nvx_scratch_journal=forbidden"), + ), + ]; + let caps = [ + (None, None), + (Some(1), Some("nvx_ram_upper_percent=1")), + (Some(90), Some("nvx_ram_upper_percent=90")), + ]; + let bounds = [ + (None, None), + (Some(1), Some("nvx_first_partition_bound=1")), + ( + Some(u64::MAX), + Some("nvx_first_partition_bound=18446744073709551615"), + ), + ]; + let mut layouts = 0; + for (bare, layer, upper) in cases { + let (journals, caps) = match bare.upper { + RootUpper::Scratch => (&journals[..], &[(None, None)][..]), + RootUpper::Ram => (&[(None, None)][..], &caps[..]), + }; + let bounds = match bare.image.format { + ImageFormat::Erofs => &[(None, None)][..], + ImageFormat::Gpt => &bounds[..], + }; + for &(overlay, overlay_token) in &overlays { + for &(journal, journal_token) in journals { + for &(max_memory_percent, cap_token) in caps { + for &(gpt_first_partition_bound, bound_token) in bounds { + let expected = RootLayout { + overlay, + journal, + gpt_first_partition_bound, + max_memory_percent, + ..bare + }; + let mut tokens = vec![layer.as_str(), upper]; + tokens.extend( + [overlay_token, journal_token, bound_token, cap_token] + .into_iter() + .flatten(), + ); + assert_eq!(expected.tokens(), tokens); + // The guest reads the tokens in any order, among others. + let reversed: Vec<&str> = tokens.iter().rev().copied().collect(); + for command_line in [ + tokens.join(" "), + reversed.join(" "), + format!( + "quiet nvx_sandbox=1 {}\nnvx_entrypoint=/bin/sh", + tokens.join("\t") + ), + ] { + assert_eq!(parse(&command_line), Ok(Some(expected)), "{command_line}"); + } + layouts += 1; + } + } + } } } + // Each of the 4 EROFS cases takes 3 overlay modes by 3 settings of its upper, and each of the + // 4 GPT cases 3 bounds more. + assert_eq!(layouts, 4 * 3 * 3 + 4 * 3 * 3 * 3); } #[test] @@ -166,10 +299,11 @@ fn every_reader_parses_the_recorded_command_lines_alike() { } } } - // The corpus holds every combination of format, identity, and upper. + // The corpus holds every combination of format, identity, and upper, and every combination of + // format, upper, and overlay mode with the settings that the format and the upper take. for format in ["erofs", "gpt"] { - for identity in [true, false] { - for upper in ["scratch", "ram"] { + for upper in ["scratch", "ram"] { + for identity in [true, false] { assert!( cases.iter().any(|case| { let layout = &case["layout"]; @@ -180,8 +314,34 @@ fn every_reader_parses_the_recorded_command_lines_alike() { "the fixture lacks {format} with identity {identity} over {upper}" ); } + let setting = match upper { + "scratch" => "journal", + _ => "max_memory_percent", + }; + for overlay in ["trusted-lowers", "untrusted-lowers"] { + assert!( + cases.iter().any(|case| { + let layout = &case["layout"]; + layout["format"] == format + && layout["upper"] == upper + && layout["overlay"] == overlay + && !layout[setting].is_null() + && (format == "erofs" || !layout["gpt_first_partition_bound"].is_null()) + }), + "the fixture lacks {format} over {upper} for {overlay} with its settings" + ); + } } } + // And each journal policy. + for journal in ["allowed", "forbidden"] { + assert!( + cases + .iter() + .any(|case| case["layout"]["journal"] == journal), + "the fixture lacks the journal policy {journal}" + ); + } } #[test] @@ -199,6 +359,24 @@ fn parsing_reports_bytes_that_are_not_text() { .message(), "the nvx_layer kernel parameter names the unsupported image format '\\xff'" ); + // So do the values of settings. + for (command_line, message) in [ + ( + b"nvx_overlay_mode=\xff".as_slice(), + "the nvx_overlay_mode kernel parameter must be trusted-lowers or untrusted-lowers, not \ + '\\xff'", + ), + ( + b"nvx_ram_upper_percent=9\xff".as_slice(), + "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, \ + without a sign or leading zeros, not '9\\xff'", + ), + ] { + assert_eq!( + RootLayout::parse(command_line).unwrap_err().message(), + message + ); + } // Other tokens may hold any bytes. assert_eq!( RootLayout::parse( @@ -227,12 +405,31 @@ fn contracted_upper(upper: RootUpper) -> &'static str { } } +/// Returns the token of `mode`, as the boot contract pins it. A new mode stops this match from +/// compiling until the boot contract names it. +fn contracted_overlay(mode: OverlayMode) -> &'static str { + match mode { + OverlayMode::TrustedLowers => "nvx_overlay_mode=trusted-lowers", + OverlayMode::UntrustedLowers => "nvx_overlay_mode=untrusted-lowers", + } +} + +/// Returns the token of `journal`, as the boot contract pins it. A new policy stops this match +/// from compiling until the boot contract names it. +fn contracted_journal(journal: Journal) -> &'static str { + match journal { + Journal::Allowed => "nvx_scratch_journal=allowed", + Journal::Forbidden => "nvx_scratch_journal=forbidden", + } +} + #[test] fn the_boot_contract_version_names_these_tokens() { // Runtime identities record the boot contract version, and a build stops, rather than // drives, a VM that a build of another version started. A change to these tokens, a new image // format or upper, or another way for the guest to assemble the root from them changes the // boot contract, and so its version, before the pins here change with it; see `contract`. + // The tokens of the root's settings joined version 5 while nothing deployed rendered them. assert_eq!( ( contract::BOOT_CONTRACT_VERSION, @@ -257,4 +454,32 @@ fn the_boot_contract_version_names_these_tokens() { } } } + // Each setting that a layout names adds its token after the upper's, in this order. + for overlay in OVERLAY_MODES { + for journal in JOURNALS { + let scratch = RootLayout { + overlay: Some(overlay), + journal: Some(journal), + gpt_first_partition_bound: Some(4096), + ..layout(RootImage::gpt(None), RootUpper::Scratch) + }; + assert_eq!( + scratch.tokens()[2..], + [ + contracted_overlay(overlay), + contracted_journal(journal), + "nvx_first_partition_bound=4096", + ] + ); + } + let ram = RootLayout { + overlay: Some(overlay), + max_memory_percent: Some(90), + ..layout(RootImage::erofs(None), RootUpper::Ram) + }; + assert_eq!( + ram.tokens()[2..], + [contracted_overlay(overlay), "nvx_ram_upper_percent=90"] + ); + } } diff --git a/nvx-rs/tests/fixtures/root-tokens.json b/nvx-rs/tests/fixtures/root-tokens.json index 1f3135d48..229aa07b2 100644 --- a/nvx-rs/tests/fixtures/root-tokens.json +++ b/nvx-rs/tests/fixtures/root-tokens.json @@ -1,5 +1,5 @@ { - "description": "Kernel command lines and the layout of a layered root that nvx::protocol::root parses from each, null for none, or the error with which it refuses the command line. The tests of nvx-rs replay them, and scripts/test_nvx_tools.py replays them through the shell init, guest/common/nvx-init-agent, which reads the same tokens: it accepts what the parser accepts of an EROFS image over either upper, and refuses everything else, with shell_error in its message where a case names one. It reads the tokens of a RAM upper beside a scratch block device too, and refuses that device when it finds it in /proc/iomem, where the kernel lists the virtio-mmio devices that it added.", + "description": "Kernel command lines and the layout of a layered root that nvx::protocol::root parses from each, null for none, or the error with which it refuses the command line. The tests of nvx-rs replay them, and scripts/test_nvx_tools.py replays them through the shell init, guest/common/nvx-init-agent, which reads the same tokens: it accepts what the parser accepts of an EROFS image over either upper, for trusted lowers and without a forbidden journal, and refuses everything else, with shell_error in its message where a case names one. It reads the tokens of a RAM upper beside a scratch block device too, and refuses that device when it finds it in /proc/iomem, where the kernel lists the virtio-mmio devices that it added.", "cases": [ {"name": "an EROFS image with its UUID over scratch", "command_line": "nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef nvx_scratch=0xd0006000,ext4", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "scratch"}}, {"name": "an EROFS image without an identity over scratch", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "layout": {"format": "erofs", "identity": null, "upper": "scratch"}}, @@ -79,6 +79,45 @@ {"name": "a RAM upper beside a scratch device token with text after its IRQ", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs virtio_mmio.device=0x1000@0xd0006000:22:", "layout": {"format": "erofs", "identity": null, "upper": "ram"}}, {"name": "a RAM upper beside a scratch device token in a quoted value", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs x=\"y virtio_mmio.device=0x1000@0xd0006000:22\"", "layout": {"format": "erofs", "identity": null, "upper": "ram"}}, {"name": "a RAM upper beside a scratch device token after --", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs -- virtio_mmio.device=0x1000@0xd0006000:22", "layout": {"format": "erofs", "identity": null, "upper": "ram"}}, - {"name": "a scratch upper beside its block device", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 virtio_mmio.device=0x1000@0xd0003000:19 virtio_mmio.device=0x1000@0xd0006000:22", "layout": {"format": "erofs", "identity": null, "upper": "scratch"}} + {"name": "a scratch upper beside its block device", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 virtio_mmio.device=0x1000@0xd0003000:19 virtio_mmio.device=0x1000@0xd0006000:22", "layout": {"format": "erofs", "identity": null, "upper": "scratch"}}, + {"name": "an EROFS image over scratch for trusted lowers, with a journal allowed", "command_line": "nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=trusted-lowers nvx_scratch_journal=allowed", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "scratch", "overlay": "trusted-lowers", "journal": "allowed"}}, + {"name": "an EROFS image over scratch for untrusted lowers, without a journal", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=untrusted-lowers nvx_scratch_journal=forbidden", "layout": {"format": "erofs", "identity": null, "upper": "scratch", "overlay": "untrusted-lowers", "journal": "forbidden"}, "shell_error": "untrusted lowers"}, + {"name": "an EROFS image in RAM for trusted lowers, with a cap", "command_line": "nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef nvx_overlay_upper=ramfs nvx_overlay_mode=trusted-lowers nvx_ram_upper_percent=25", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "ram", "overlay": "trusted-lowers", "max_memory_percent": 25}}, + {"name": "an EROFS image in RAM for untrusted lowers, with the largest cap, its settings first", "command_line": "nvx_ram_upper_percent=90 nvx_overlay_mode=untrusted-lowers nvx_overlay_upper=ramfs nvx_layer=distro,0xd0003000,erofs", "layout": {"format": "erofs", "identity": null, "upper": "ram", "overlay": "untrusted-lowers", "max_memory_percent": 90}, "shell_error": "untrusted lowers"}, + {"name": "a GPT image over scratch for trusted lowers, without a journal, with the smallest bound", "command_line": "nvx_layer=distro,0xd0003000,gpt:fedcba98-7654-3210-fedc-ba9876543210 nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=trusted-lowers nvx_scratch_journal=forbidden nvx_first_partition_bound=1", "layout": {"format": "gpt", "identity": "fedcba98-7654-3210-fedc-ba9876543210", "upper": "scratch", "overlay": "trusted-lowers", "journal": "forbidden", "gpt_first_partition_bound": 1}, "shell_error": "GPT image"}, + {"name": "a GPT image over scratch for untrusted lowers, with a journal allowed and the largest bound", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=untrusted-lowers nvx_scratch_journal=allowed nvx_first_partition_bound=18446744073709551615", "layout": {"format": "gpt", "identity": null, "upper": "scratch", "overlay": "untrusted-lowers", "journal": "allowed", "gpt_first_partition_bound": 18446744073709551615}, "shell_error": "GPT image"}, + {"name": "a GPT image in RAM for trusted lowers, with the smallest cap and a bound", "command_line": "nvx_layer=distro,0xd0003000,gpt:fedcba98-7654-3210-fedc-ba9876543210 nvx_overlay_upper=ramfs nvx_overlay_mode=trusted-lowers nvx_ram_upper_percent=1 nvx_first_partition_bound=4096", "layout": {"format": "gpt", "identity": "fedcba98-7654-3210-fedc-ba9876543210", "upper": "ram", "overlay": "trusted-lowers", "max_memory_percent": 1, "gpt_first_partition_bound": 4096}, "shell_error": "GPT image"}, + {"name": "a GPT image in RAM for untrusted lowers, with a cap and a bound", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_overlay_upper=ramfs nvx_overlay_mode=untrusted-lowers nvx_ram_upper_percent=50 nvx_first_partition_bound=65536", "layout": {"format": "gpt", "identity": null, "upper": "ram", "overlay": "untrusted-lowers", "max_memory_percent": 50, "gpt_first_partition_bound": 65536}, "shell_error": "GPT image"}, + {"name": "an EROFS image over scratch without a journal", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_scratch_journal=forbidden", "layout": {"format": "erofs", "identity": null, "upper": "scratch", "journal": "forbidden"}, "shell_error": "forbids the scratch a journal"}, + {"name": "the settings among the tokens of a managed sandbox", "command_line": "quiet nvx_ram_upper_percent=90 nvx_sandbox=1 nvx_overlay_mode=trusted-lowers nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef nvx_overlay_upper=ramfs nvx_entrypoint=/bin/sh", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "ram", "overlay": "trusted-lowers", "max_memory_percent": 90}}, + {"name": "parameters whose names only begin or end like the settings' tokens", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_modes=x nvx_overlay_mode_x=1 nvx-overlay-mode=untrusted-lowers nvx_scratch_journals=forbidden nvx_first_partition_bounds=0 nvx_ram_upper=0 nvx_ram_upper_percents=0 xnvx_ram_upper_percent=0", "layout": {"format": "erofs", "identity": null, "upper": "scratch"}}, + {"name": "a capped RAM upper with a scratch block device", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=40 virtio_mmio.device=0x1000@0xd0006000:22", "error": "a RAM upper takes no scratch block device, but the command line adds a virtio-mmio device at 0xd0006000"}, + {"name": "an overlay mode without a value", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_mode", "error": "the nvx_overlay_mode kernel parameter has no value"}, + {"name": "a bound without a value, before its image", "command_line": "nvx_first_partition_bound nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter has no value"}, + {"name": "two journal policies", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_scratch_journal=allowed nvx_scratch_journal=allowed", "error": "the nvx_scratch_journal kernel parameter is repeated"}, + {"name": "two caps", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=50 nvx_ram_upper_percent=60", "error": "the nvx_ram_upper_percent kernel parameter is repeated"}, + {"name": "an unknown overlay mode", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=trusted", "error": "the nvx_overlay_mode kernel parameter must be trusted-lowers or untrusted-lowers, not 'trusted'"}, + {"name": "an overlay mode in capitals", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=TRUSTED-LOWERS", "error": "the nvx_overlay_mode kernel parameter must be trusted-lowers or untrusted-lowers, not 'TRUSTED-LOWERS'"}, + {"name": "an overlay mode with an empty value", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=", "error": "the nvx_overlay_mode kernel parameter must be trusted-lowers or untrusted-lowers, not ''"}, + {"name": "an unknown journal policy", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_scratch_journal=none", "error": "the nvx_scratch_journal kernel parameter must be allowed or forbidden, not 'none'"}, + {"name": "a cap of 0, which the kernel reads as none", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=0", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '0'"}, + {"name": "a cap past 90 percent", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=91", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '91'"}, + {"name": "a cap past 8 bits", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=256", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '256'"}, + {"name": "a cap with a leading zero", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=050", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '050'"}, + {"name": "a cap with a sign", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=+50", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '+50'"}, + {"name": "a cap with a percent sign", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=50%", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '50%'"}, + {"name": "a cap with an empty value", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not ''"}, + {"name": "a bound of 0, before its image", "command_line": "nvx_first_partition_bound=0 nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '0'"}, + {"name": "a hexadecimal bound, before its image", "command_line": "nvx_first_partition_bound=0x1000 nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '0x1000'"}, + {"name": "a bound past 64 bits", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4 nvx_first_partition_bound=18446744073709551616", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'"}, + {"name": "a bound past 64 bits before an EROFS image", "command_line": "nvx_first_partition_bound=18446744073709551616 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'", "shell_error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'"}, + {"name": "a 21 digit bound before an EROFS image", "command_line": "nvx_first_partition_bound=184467440737095516150 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '184467440737095516150'", "shell_error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '184467440737095516150'"}, + {"name": "the largest bound before an EROFS image", "command_line": "nvx_first_partition_bound=18446744073709551615 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter needs a GPT image", "shell_error": "the nvx_first_partition_bound kernel parameter needs a GPT image"}, + {"name": "a 20 digit bound below the largest before an EROFS image", "command_line": "nvx_first_partition_bound=17999999999999999999 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter needs a GPT image", "shell_error": "the nvx_first_partition_bound kernel parameter needs a GPT image"}, + {"name": "a journal policy in RAM", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_scratch_journal=allowed", "error": "the nvx_scratch_journal kernel parameter needs a scratch upper, nvx_scratch"}, + {"name": "a cap over scratch", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_ram_upper_percent=50", "error": "the nvx_ram_upper_percent kernel parameter needs a RAM upper, nvx_overlay_upper"}, + {"name": "a bound of an EROFS image", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_first_partition_bound=4096", "error": "the nvx_first_partition_bound kernel parameter needs a GPT image"}, + {"name": "settings without a root", "command_line": "quiet nvx_overlay_mode=trusted-lowers nvx_scratch_journal=allowed", "error": "the nvx_overlay_mode kernel parameter needs an image, which nvx_layer names"}, + {"name": "a cap before a RAM upper without an image", "command_line": "nvx_ram_upper_percent=50 nvx_overlay_upper=ramfs", "error": "the nvx_ram_upper_percent kernel parameter needs an image, which nvx_layer names"} ] } diff --git a/scripts/test_nvx_tools.py b/scripts/test_nvx_tools.py index 402f0c285..c653e4a84 100644 --- a/scripts/test_nvx_tools.py +++ b/scripts/test_nvx_tools.py @@ -19407,7 +19407,8 @@ def setUp(self): def _read(self, command_line: str) -> subprocess.CompletedProcess[str]: """Runs the agent's reader of the root's boot tokens on `command_line`, - and prints the UUID that it expects of the distro layer and the upper.""" + and prints the UUID that it expects of the distro layer, the upper, and + the RAM upper's cap.""" functions = "".join( _shell_function(self.source, name) for name in ("is_canonical_uuid", "read_layer_token", "read_root_tokens") @@ -19424,7 +19425,7 @@ def _read(self, command_line: str) -> subprocess.CompletedProcess[str]: 'fatal() { echo "FATAL: $*" >&2; exit 125; }\n' f"{functions}" "read_root_tokens\n" - 'echo "layer_uuid=$layer_uuid upper=$upper"\n' + 'echo "layer_uuid=$layer_uuid upper=$upper ram_percent=$ram_percent"\n' ), text=True, capture_output=True, @@ -19434,7 +19435,7 @@ def _read(self, command_line: str) -> subprocess.CompletedProcess[str]: def test_agent_reads_the_boot_tokens_as_nvx_rs_reads_them(self): cases = json.loads(self.FIXTURE.read_text(encoding="utf-8"))["cases"] accepted: set[str] = set() - devices = 0 + capped = devices = 0 for case in cases: with self.subTest(case=case["name"]): result = self._read(case["command_line"]) @@ -19442,23 +19443,35 @@ def test_agent_reads_the_boot_tokens_as_nvx_rs_reads_them(self): if case.get("error") == self.SCRATCH_DEVICE_ERROR: self.assertNotIn("shell_error", case) self.assertEqual(result.returncode, 0, result.stderr) - self.assertRegex(result.stdout, r"\Alayer_uuid=\S* upper=ram\n\Z") + self.assertRegex( + result.stdout, r"\Alayer_uuid=\S* upper=ram ram_percent=\d*\n\Z" + ) devices += 1 continue - if layout is not None and layout["format"] == "erofs": + # The shell init assembles an EROFS image for trusted lowers, and + # cannot check that a scratch has no journal. + if ( + layout is not None + and layout["format"] == "erofs" + and layout.get("overlay") != "untrusted-lowers" + and layout.get("journal") != "forbidden" + ): self.assertNotIn("shell_error", case) self.assertEqual(result.returncode, 0, result.stderr) identity = layout["identity"] or "" + cap = layout.get("max_memory_percent", "") self.assertEqual( result.stdout, - f"layer_uuid={identity} upper={layout['upper']}\n", + f"layer_uuid={identity} upper={layout['upper']} ram_percent={cap}\n", ) accepted.add(layout["upper"]) + capped += cap != "" continue self.assertEqual(result.returncode, 125, result.stdout) self.assertRegex(result.stderr, r"\AFATAL: [^\n]+\n\Z") self.assertIn(case.get("shell_error", ""), result.stderr) self.assertEqual(accepted, {"scratch", "ram"}) + self.assertGreater(capped, 0) self.assertGreater(devices, 0) def test_agent_explains_the_roots_that_it_cannot_assemble(self): @@ -19473,6 +19486,83 @@ def test_agent_explains_the_roots_that_it_cannot_assemble(self): f"nvx_layer=distro,0xd0003000,gpt:{self.UUID} names a GPT image, " "which this init cannot assemble: it assembles only an EROFS image", ), + ( + "nvx_overlay_mode=untrusted-lowers nvx_layer=distro,0xd0003000,erofs " + "nvx_overlay_upper=ramfs", + "nvx_overlay_mode=untrusted-lowers names untrusted lowers, which " + "this init cannot assemble: it mounts the overlay only for trusted " + "lowers, with metacopy=on,xino=on", + ), + ( + "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 " + "nvx_scratch_journal=forbidden", + "nvx_scratch_journal=forbidden forbids the scratch a journal, which " + "this init cannot check: it reads nothing of the scratch's superblock", + ), + ): + with self.subTest(command_line=command_line): + result = self._read(command_line) + self.assertEqual(result.returncode, 125, result.stdout) + self.assertEqual(result.stderr, f"FATAL: {message}\n") + + def test_agent_refuses_the_settings_with_the_messages_of_nvx_rs(self): + scratch = "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4" + ram = "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs" + number = "a decimal number from 1 to {}, without a sign or leading zeros" + percent, bound = number.format(90), number.format(2**64 - 1) + for command_line, message in ( + ( + f"{scratch} nvx_scratch_journal", + "the nvx_scratch_journal kernel parameter has no value", + ), + ( + f"{ram} nvx_ram_upper_percent=5 nvx_ram_upper_percent=5", + "the nvx_ram_upper_percent kernel parameter is repeated", + ), + ( + f"{scratch} nvx_overlay_mode=trusted", + "the nvx_overlay_mode kernel parameter must be trusted-lowers or " + "untrusted-lowers, not 'trusted'", + ), + ( + f"{scratch} nvx_scratch_journal=", + "the nvx_scratch_journal kernel parameter must be allowed or " + "forbidden, not ''", + ), + ( + f"{ram} nvx_ram_upper_percent=91", + f"the nvx_ram_upper_percent kernel parameter must be {percent}, not '91'", + ), + ( + f"{ram} nvx_ram_upper_percent=09", + f"the nvx_ram_upper_percent kernel parameter must be {percent}, not '09'", + ), + ( + f"nvx_first_partition_bound=-1 {scratch}", + f"the nvx_first_partition_bound kernel parameter must be {bound}, " + "not '-1'", + ), + ( + f"{ram} nvx_scratch_journal=allowed", + "the nvx_scratch_journal kernel parameter needs a scratch upper, " + "nvx_scratch", + ), + ( + f"{scratch} nvx_ram_upper_percent=50", + "the nvx_ram_upper_percent kernel parameter needs a RAM upper, " + "nvx_overlay_upper", + ), + ( + f"{scratch} nvx_first_partition_bound=4096", + "the nvx_first_partition_bound kernel parameter needs a GPT image", + ), + # The parser's refusals come before those of what this init cannot + # assemble. + ( + f"{scratch} nvx_overlay_mode=untrusted-lowers nvx_scratch_journal=no", + "the nvx_scratch_journal kernel parameter must be allowed or " + "forbidden, not 'no'", + ), ): with self.subTest(command_line=command_line): result = self._read(command_line) @@ -19573,9 +19663,10 @@ def test_agent_checks_a_ram_upper_before_it_mounts_anything(self): def test_agent_mounts_the_upper_that_the_tokens_name(self): runtime = (self.root / "run").as_posix() log = self.root / "commands.log" - for upper, commands, point, name in ( + for upper, ram_percent, commands, point, name in ( ( "scratch", + "", [ f"mkdir -p {runtime}/scratch", "resolve 0xd0006000", @@ -19588,6 +19679,7 @@ def test_agent_mounts_the_upper_that_the_tokens_name(self): ), ( "ram", + "", [ f"mkdir -p {runtime}/tmpfs", f"mount -t tmpfs -o mode=0755,nosuid,nodev tmpfs {runtime}/tmpfs", @@ -19596,14 +19688,34 @@ def test_agent_mounts_the_upper_that_the_tokens_name(self): f"{runtime}/tmpfs", "the tmpfs upper", ), + ( + "ram", + "25", + [ + f"mkdir -p {runtime}/tmpfs", + "mount -t tmpfs -o mode=0755,nosuid,nodev,size=25% tmpfs " + f"{runtime}/tmpfs", + f"mkdir -p {runtime}/tmpfs/upper {runtime}/tmpfs/work", + ], + f"{runtime}/tmpfs", + "the tmpfs upper", + ), ): - with self.subTest(upper=upper): + with self.subTest(upper=upper, ram_percent=ram_percent): log.unlink(missing_ok=True) result = subprocess.run( - [self.shell, "-s", "--", runtime, log.as_posix(), upper], + [ + self.shell, + "-s", + "--", + runtime, + log.as_posix(), + upper, + ram_percent, + ], input=( "set -eu\n" - "runtime=$1\nlog=$2\nupper=$3\n" + "runtime=$1\nlog=$2\nupper=$3\nram_percent=$4\n" "scratch=$runtime/scratch\ntmpfs=$runtime/tmpfs\n" 'fatal() { echo "FATAL: $*" >&2; exit 125; }\n' 'resolve_block_device() { echo "resolve $*" >>"$log"; ' diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index d28a31e78..5a54b719e 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -91,9 +91,11 @@ what the guest did. ### Root configurations The boot tokens of `nvx::protocol::root` carry what NVX's boot contract -defines: the image's format, the identity that the guest expects of it, and -the upper. A root configuration, `root::RootConfig`, carries the rest that the -root needs, which only the adapter defines, once the host attaches. It is +defines: the image's format, the identity that the guest expects of it, the +upper, and the root's settings. The adapter's plan names none of the settings, +and the worker reads only a RAM upper's cap from them. A root configuration, +`root::RootConfig`, carries the rest that the root needs, which only the +adapter defines, once the host attaches. It is UTF-8 text: one `NAME=VALUE` line per field, each ended by a newline, in any order, and each at most once. Numbers are decimal, without a sign or leading zeros: diff --git a/tests/runtime-adapter/src/worker.rs b/tests/runtime-adapter/src/worker.rs index a0aa84bdd..dc166c88d 100644 --- a/tests/runtime-adapter/src/worker.rs +++ b/tests/runtime-adapter/src/worker.rs @@ -1236,11 +1236,11 @@ impl RootOperations for LibraryRoot { /// the EROFS image, with the identity that the boot tokens expect of it, or the GPT disk, with /// the disk GUID that they expect of it, whose first partition holds at most the /// configuration's bound, over the ext4 scratch, with the configuration's journal policy, or - /// over a tmpfs, in the configuration's overlay mode, with the configuration's runtime - /// directory, device sizes, and timeout. A bound, a policy, or a mode that the configuration - /// does not name is the guest's default: the first partition holds at most - /// [`DEFAULT_FIRST_PARTITION_BOUND`] bytes, a scratch may have a journal, and the overlay - /// trusts its lowers. + /// over a tmpfs, with the cap that the boot tokens name, if any, in the configuration's + /// overlay mode, with the configuration's runtime directory, device sizes, and timeout. A + /// bound, a policy, or a mode that the configuration does not name is the guest's default: the + /// first partition holds at most [`DEFAULT_FIRST_PARTITION_BOUND`] bytes, a scratch may have a + /// journal, and the overlay trusts its lowers. fn assemble(&mut self, config: &RootConfig, layout: &RootLayout) -> Result<(), String> { let image = match layout.image.format { ImageFormat::Erofs => Image::Erofs { @@ -1274,7 +1274,9 @@ impl RootOperations for LibraryRoot { Some(JournalPolicy::Forbidden) => Journal::Forbidden, }, }, - RootUpper::Ram => Upper::Tmpfs, + RootUpper::Ram => Upper::Tmpfs { + max_memory_percent: layout.max_memory_percent, + }, }; let overlay = match config.overlay_mode { None => root::OverlayMode::default(), diff --git a/tests/runtime-adapter/src/worker/activation/tests.rs b/tests/runtime-adapter/src/worker/activation/tests.rs index e2ac0c156..cbc439cb4 100644 --- a/tests/runtime-adapter/src/worker/activation/tests.rs +++ b/tests/runtime-adapter/src/worker/activation/tests.rs @@ -150,7 +150,9 @@ fn each_class_of_failure_ends_the_clone_with_its_own_status() { sectors: None, }, image: root::Image::Erofs { uuid: None }, - upper: root::Upper::Tmpfs, + upper: root::Upper::Tmpfs { + max_memory_percent: None, + }, overlay: root::OverlayMode::default(), timeout: Duration::ZERO, }; diff --git a/tests/runtime-adapter/tests/guest_session.rs b/tests/runtime-adapter/tests/guest_session.rs index 4980e9e67..61777bc98 100644 --- a/tests/runtime-adapter/tests/guest_session.rs +++ b/tests/runtime-adapter/tests/guest_session.rs @@ -1631,11 +1631,15 @@ impl Drop for Workload { } /// Returns a layout that the worker assembles: an EROFS image, with the identity that the guest -/// expects of it, over an ext4 scratch. +/// expects of it, over an ext4 scratch, with none of the root's settings. fn layout() -> RootLayout { RootLayout { image: RootImage::erofs(Uuid::parse("0a490905-41be-ed5f-984e-63eb7e5e20b1")), upper: RootUpper::Scratch, + overlay: None, + journal: None, + gpt_first_partition_bound: None, + max_memory_percent: None, } } @@ -1815,8 +1819,10 @@ fn the_worker_assembles_the_root_serves_it_and_tears_it_down_on_stop() { #[test] fn the_worker_assembles_an_image_over_ram_in_the_overlay_mode_of_its_configuration() { + // The boot tokens cap the RAM upper, and the worker hands the cap to the root in the layout. let ram = RootLayout { upper: RootUpper::Ram, + max_memory_percent: Some(25), ..layout() }; let config = RootConfig { @@ -1843,7 +1849,7 @@ fn the_worker_assembles_an_image_over_ram_in_the_overlay_mode_of_its_configurati fn the_worker_assembles_a_gpt_disk_with_the_first_partition_bound_of_its_configuration() { let gpt = RootLayout { image: RootImage::gpt(Uuid::parse("6c1f5e0a-2b7d-4c39-9e81-3a5d0f7b2c64")), - upper: RootUpper::Scratch, + ..layout() }; let config = RootConfig { first_partition_bound: Some(4096), diff --git a/tests/runtime-adapter/tests/layered_launch.rs b/tests/runtime-adapter/tests/layered_launch.rs index 2ac965a45..af6ed2c19 100644 --- a/tests/runtime-adapter/tests/layered_launch.rs +++ b/tests/runtime-adapter/tests/layered_launch.rs @@ -259,6 +259,10 @@ fn a_layered_launch_plans_its_blocks_and_boot_tokens_with_the_adapters_plan() { let layout = RootLayout { image: RootImage::erofs(Some(identity())), upper: RootUpper::Scratch, + overlay: None, + journal: None, + gpt_first_partition_bound: None, + max_memory_percent: None, }; assert_eq!(launch.plan.layout(), layout); // The adapter's plan holds none of the settings of NVX's sandbox profile. From 8425d436da4aa3670956d8214fa73d2dcb6bafa1 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 14:46:37 -0800 Subject: [PATCH 05/29] test-adversarial: await the fixture's surviving child before cleanup The executor-close fixture kills its surviving Windows child, whose stderr still names a file in its temporary directory. Wait for the process object to signal before cleaning up that directory, using the existing bounded Windows wait helper and the fixture's existing cleanup deadline. Keep the workload timeout, the measured close bound, and every existing test assertion unchanged. A child that does not exit now fails explicitly instead of racing the temporary-directory cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e354d65-494d-4fa9-ae9b-4f2e86c3c0f8 --- scripts/test_adversarial.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/test_adversarial.py b/scripts/test_adversarial.py index 1b3cb3a2c..53acc998c 100644 --- a/scripts/test_adversarial.py +++ b/scripts/test_adversarial.py @@ -76,7 +76,11 @@ run_bounded_process, terminate_process_tree, ) -from nvx_tools.benchmark import InteractiveProcess, run_guest_script +from nvx_tools.benchmark import ( + InteractiveProcess, + _wait_for_windows_process, + run_guest_script, +) from nvx_tools.build_constants import ( AlpineBuildConstants, InitramfsBuildConstants, @@ -3209,6 +3213,12 @@ def test_close_is_bounded_when_a_surviving_child_holds_executor_stdout(self): check=False, capture_output=True, ) + self.assertTrue( + _wait_for_windows_process( + int(pid), max(0.0, deadline - time.monotonic()) + ), + f"surviving child {pid} did not exit after taskkill", + ) else: os.kill(int(pid), signal.SIGKILL) From ebbb95658ed62f7688ba5092898e8a781f53ee21 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 15:44:01 -0800 Subject: [PATCH 06/29] test-adversarial: ready the contained fixture before timing its work Wait for the real fixture to publish its boot readiness and for its descendant to publish its PID before starting the existing half-second workload timeout. Keep that startup wait bounded and clean up the contained processes if readiness fails. Accept the runner's raw TimeoutError and its RuntimeError wrapper only with the expected half-second workload-timeout message. Require a live, positive descendant PID before the workload and retain the check that it has exited after the timeout. Production code, measured deadlines, and skips do not change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e354d65-494d-4fa9-ae9b-4f2e86c3c0f8 --- scripts/test_adversarial.py | 54 ++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/scripts/test_adversarial.py b/scripts/test_adversarial.py index 53acc998c..d694ba419 100644 --- a/scripts/test_adversarial.py +++ b/scripts/test_adversarial.py @@ -857,6 +857,7 @@ def spawn_unrelated( def test_contained_guest_runner_timeout_kills_descendants(self) -> None: with tempfile.TemporaryDirectory() as temporary: child_pid_path = Path(temporary) / "child.pid" + ready_path = Path(temporary) / "ready" command = [ sys.executable, "-u", @@ -864,13 +865,58 @@ def test_contained_guest_runner_timeout_kills_descendants(self) -> None: ( "import pathlib,subprocess,sys,time; " "child=subprocess.Popen([sys.executable,'-c'," - "'import time; time.sleep(60)']); " - "pathlib.Path(sys.argv[1]).write_text(str(child.pid)); " - "print('ALPINE-MICROVM-BOOT-OK',flush=True); time.sleep(60)" + "'import os,pathlib,sys,time; " + "path=pathlib.Path(sys.argv[1]); " + "path.with_suffix(\".tmp\").write_text(str(os.getpid())); " + "path.with_suffix(\".tmp\").replace(path); " + "time.sleep(60)',sys.argv[1]]); " + "print('ALPINE-MICROVM-BOOT-OK',flush=True); " + "pathlib.Path(sys.argv[2]).touch(); time.sleep(60)" ), str(child_pid_path), + str(ready_path), ] - with self.assertRaisesRegex(RuntimeError, "did not finish"): + + def start_ready( + command: Sequence[str], + environment: dict[str, str], + *, + contain_process_tree: bool, + separate_stderr: bool, + ) -> InteractiveProcess: + interaction = InteractiveProcess( + command, + environment, + contain_process_tree=contain_process_tree, + separate_stderr=separate_stderr, + ) + ready = False + try: + deadline = time.monotonic() + 5.0 + while not ready_path.exists() or not child_pid_path.exists(): + self.assertIsNone( + interaction.process.poll(), "fixture exited before readiness" + ) + self.assertLess( + time.monotonic(), deadline, "fixture did not become ready" + ) + time.sleep(0.01) + child_pid = int(child_pid_path.read_text(encoding="utf-8")) + self.assertGreater(child_pid, 0) + self.assertTrue(_process_running(child_pid)) + ready = True + return interaction + finally: + if not ready: + interaction.close() + + with ( + patch("nvx_tools.benchmark.InteractiveProcess", side_effect=start_ready), + self.assertRaisesRegex( + (TimeoutError, RuntimeError), + r"^guest workload did not finish within 0\.5s(?:\n|$)", + ), + ): run_guest_script( command, "exit\n", From a9add81a0afc662e96871c3f5361b7a01cbe25f0 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 15:48:01 -0800 Subject: [PATCH 07/29] test-adversarial: format the contained fixture's readiness helper Apply the repository formatter's quoting and wrapping to the approved fixture correction. Its parsed Python syntax tree is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e354d65-494d-4fa9-ae9b-4f2e86c3c0f8 --- scripts/test_adversarial.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/test_adversarial.py b/scripts/test_adversarial.py index d694ba419..674fe88ea 100644 --- a/scripts/test_adversarial.py +++ b/scripts/test_adversarial.py @@ -867,8 +867,8 @@ def test_contained_guest_runner_timeout_kills_descendants(self) -> None: "child=subprocess.Popen([sys.executable,'-c'," "'import os,pathlib,sys,time; " "path=pathlib.Path(sys.argv[1]); " - "path.with_suffix(\".tmp\").write_text(str(os.getpid())); " - "path.with_suffix(\".tmp\").replace(path); " + 'path.with_suffix(".tmp").write_text(str(os.getpid())); ' + 'path.with_suffix(".tmp").replace(path); ' "time.sleep(60)',sys.argv[1]]); " "print('ALPINE-MICROVM-BOOT-OK',flush=True); " "pathlib.Path(sys.argv[2]).touch(); time.sleep(60)" @@ -895,7 +895,8 @@ def start_ready( deadline = time.monotonic() + 5.0 while not ready_path.exists() or not child_pid_path.exists(): self.assertIsNone( - interaction.process.poll(), "fixture exited before readiness" + interaction.process.poll(), + "fixture exited before readiness", ) self.assertLess( time.monotonic(), deadline, "fixture did not become ready" @@ -911,7 +912,9 @@ def start_ready( interaction.close() with ( - patch("nvx_tools.benchmark.InteractiveProcess", side_effect=start_ready), + patch( + "nvx_tools.benchmark.InteractiveProcess", side_effect=start_ready + ), self.assertRaisesRegex( (TimeoutError, RuntimeError), r"^guest workload did not finish within 0\.5s(?:\n|$)", From d9cf939b1d52fe32b7d0b2542d846e9821673d35 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Thu, 5 Nov 2026 13:13:49 -0800 Subject: [PATCH 08/29] host: make the network and the cold boot part of a VM's configuration Add net::Ipv4Cidr, a static guest address checked when it is built: an IPv4 address with a /1 to /30 prefix that is not its network's own, broadcast, or gateway address, as OpenVMM accepts it. It displays and parses as ADDRESS/PREFIX, and with the serde feature it serializes as that text. is_valid_guest_network and guest_gateway read addresses through it, with the same rules. VmConfig gains its network, a NetworkConfig of the policy that OpenVMM enforces and the NIC's address, which a cold boot without one takes from DEFAULT_GUEST_NETWORK, and a restore from the snapshot; and its boot, a Boot of the caller's own kernel command-line tokens, which only a cold boot takes. NicConfig names the NIC that a VM settled on. DEFAULT_GUEST_NETWORK moves here from host::launch, which re-exports it. VmConfig::check adds no refusal: the policy and the tokens are checked where they are lowered. The configuration tests' fixture gains the two fields. The public API snapshots list the new items. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ab593c71-7f27-4207-8ff3-829d9a283b4c --- nvx-rs/Cargo.toml | 2 +- nvx-rs/README.md | 30 ++++++-- nvx-rs/public-api/linux.txt | 15 ++++ nvx-rs/public-api/windows.txt | 15 ++++ nvx-rs/src/host/config/mod.rs | 41 ++++++++++- nvx-rs/src/host/config/tests.rs | 39 ++++++++++ nvx-rs/src/host/launch.rs | 4 +- nvx-rs/src/host/mod.rs | 3 +- nvx-rs/src/lib.rs | 2 +- nvx-rs/src/net/mod.rs | 121 +++++++++++++++++++++++++++----- nvx-rs/src/net/tests.rs | 80 +++++++++++++++++++++ 11 files changed, 321 insertions(+), 31 deletions(-) diff --git a/nvx-rs/Cargo.toml b/nvx-rs/Cargo.toml index ddad5ac91..804ce2173 100644 --- a/nvx-rs/Cargo.toml +++ b/nvx-rs/Cargo.toml @@ -44,7 +44,7 @@ testing = ["dep:getrandom", "dep:serde", "dep:serde_json", "dep:nix", "dep:windo # NVX's own `nvx-rs` binary, the command-line interface that runs NVX's microVMs and sandboxes, # compiles egress policies, looks up artifacts, and downloads releases. It adds no API. cli = ["host"] -# The serde form of `Hypervisor`. The `host` feature enables it. +# The serde forms of `Hypervisor` and `net::Ipv4Cidr`. The `host` feature enables it. serde = ["dep:serde"] # The programs that run in Linux guests: the `guest` module and the `nvx-*` guest binaries. Build # them with `--no-default-features --features guest` and the `guest` profile; see README.md. diff --git a/nvx-rs/README.md b/nvx-rs/README.md index ba35c4468..e814382c9 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -123,7 +123,7 @@ changes only when the module's purpose does. | [`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::config`](#nvxhostconfig) | `VmConfig`: a VM's processors, memory, root, view, export, and boot console as one value, checked without reading the host | +| [`nvx::host::config`](#nvxhostconfig) | `VmConfig`: a VM's processors, memory, root, view, network, export, boot console, and cold-boot settings as one value, checked without reading the host | | [`nvx::host::artifacts`](#nvxhostartifacts) | Locating the OpenVMM executable and the guest artifacts that a VM runs, and their runtime identity | | [`nvx::host::launch`](#nvxhostlaunch) | `LaunchSpec` and `VmmLaunch`: the OpenVMM command line of a managed microVM, its checks, and the inputs that it shares with an adapter's launch | | [`nvx::host::network`](#nvxhostnetwork) | The one lowering of a network posture into OpenVMM options, for the CLI and external orchestrators | @@ -332,6 +332,11 @@ an egress rule; `is_valid_hostname` and `is_valid_guest_network`, the forms of a VM's hostname and guest address, and `guest_gateway`, the gateway of a guest address. +`Ipv4Cidr`: a guest address, checked when it is built, which `Ipv4Cidr::new` and +`Ipv4Cidr::parse` refuse unless OpenVMM accepts it, as `is_valid_guest_network` +does; it displays and parses as `ADDRESS/PREFIX`, such as `10.0.0.2/24`, and +with the `serde` feature it serializes as that text. + ### `nvx::gpt` `Disk::read`: the protective MBR, primary header, and primary partition entry @@ -450,10 +455,11 @@ file; a caller that reconnects after a restart keeps it. ### `nvx::host::config` `VmConfig`: the configuration of a VM as one value, its processors, its memory -in MiB, its `RootConfig`, the `View` that its workload has of it, the -`ExportPlan` of the host paths that it exports, if any, and the -`ConsoleAttachment` of its boot console, a `Listener` for a client or -`Disconnected`; the console's device exists either way. +in MiB, its `RootConfig`, the `View` that its workload has of it, its +`NetworkConfig` if it has a NIC, the `ExportPlan` of the host paths that it +exports, if any, the `ConsoleAttachment` of its boot console, a `Listener` for a +client or `Disconnected`, whose device exists either way, and a `Boot`, what +only a cold boot takes. A root is `Blockless`, the guest's own system from its initramfs, or `Layered`, a `LayeredRoot`: the path of an image and its `RootImage`, EROFS or a GPT disk, @@ -463,13 +469,23 @@ upper, a scratch image with its `Journal` policy, or RAM up to a cap of 1 to 90 percent of the VM's memory, 50 when the caller has no other (`UpperConfig::DEFAULT_MAX_MEMORY_PERCENT`). +A `NetworkConfig` holds the `NetworkPolicy` that OpenVMM enforces and the NIC's +address, an `nvx::net::Ipv4Cidr`, if the caller names one: without one, a cold +boot takes `DEFAULT_GUEST_NETWORK`, `10.0.0.2/24`, which `nvx::host::launch` +also exports, and a restore the snapshot's. A `NicConfig` is the NIC that a VM +settled on, with its address. A `Boot` holds the caller's own kernel +command-line tokens, to which NVX adds those of what it lowers: a VM that boots +needs one, and a VM that a snapshot restores takes none. + `VmConfig::check` checks the whole value without reading the host: it opens no path and checks no support of the host. It fails with an `nvx::Error` of kind `Unsupported` that names the first refusal: no processor or no memory; a layered root without an image or a scratch path, with a bound of an EROFS image or of 0, or with a RAM upper's cap outside 1 to 90 percent; a hostname that an activation record refuses; or an export that `ExportPlan::check` refuses for the guest -targets that the root reserves. +targets that the root reserves. The network's policy and the boot's command line +are checked where they are lowered, with the guest's address and NVX's own +tokens. ### `nvx::host::artifacts` @@ -2618,7 +2634,7 @@ caller owner. | `bundled` | Stages a release at build time; see [Bundled artifacts](#bundled-artifacts) | | `testing` | NVX's own `nvx-fake-openvmm` binary, a fake OpenVMM for tests; see [Test hooks](#test-hooks). It adds no API | | `cli` | NVX's own `nvx-rs` binary; it adds no API, and enables `host`. See [The nvx-rs CLI](#the-nvx-rs-cli) | -| `serde` | The serde form of `Hypervisor` | +| `serde` | The serde forms of `Hypervisor` and `net::Ipv4Cidr` | | `guest` | The `guest` module and the guest binaries; see [Guest binaries](#guest-binaries) | Without default features, the crate has only `protocol`, `activation`, `exec`, diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 02b5bdeaf..6be313fd4 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -1750,15 +1750,23 @@ fn nvx::host::artifacts::openvmm_executable mod nvx::host::cli (hidden) fn nvx::host::cli::main (hidden) mod nvx::host::config +struct nvx::host::config::Boot +field nvx::host::config::Boot::command_line enum nvx::host::config::ConsoleAttachment variant nvx::host::config::ConsoleAttachment::Disconnected variant nvx::host::config::ConsoleAttachment::Listener +const nvx::host::config::DEFAULT_GUEST_NETWORK struct nvx::host::config::LayeredRoot field nvx::host::config::LayeredRoot::format field nvx::host::config::LayeredRoot::gpt_first_partition_bound field nvx::host::config::LayeredRoot::image field nvx::host::config::LayeredRoot::overlay field nvx::host::config::LayeredRoot::upper +struct nvx::host::config::NetworkConfig +field nvx::host::config::NetworkConfig::guest_network +field nvx::host::config::NetworkConfig::policy +struct nvx::host::config::NicConfig +field nvx::host::config::NicConfig::guest_network enum nvx::host::config::RootConfig variant nvx::host::config::RootConfig::Blockless variant nvx::host::config::RootConfig::Layered @@ -1771,10 +1779,12 @@ variant nvx::host::config::UpperConfig::Scratch field nvx::host::config::UpperConfig::Scratch::journal field nvx::host::config::UpperConfig::Scratch::path struct nvx::host::config::VmConfig +field nvx::host::config::VmConfig::boot method nvx::host::config::VmConfig::check field nvx::host::config::VmConfig::console field nvx::host::config::VmConfig::filesystem field nvx::host::config::VmConfig::memory_mib +field nvx::host::config::VmConfig::network field nvx::host::config::VmConfig::processors field nvx::host::config::VmConfig::root field nvx::host::config::VmConfig::view @@ -3170,6 +3180,11 @@ method nvx::net::Cidr::is_ipv6 method nvx::net::Cidr::parse method nvx::net::Cidr::parse_masked method nvx::net::Cidr::subtract +struct nvx::net::Ipv4Cidr +method nvx::net::Ipv4Cidr::address +method nvx::net::Ipv4Cidr::new +method nvx::net::Ipv4Cidr::parse +method nvx::net::Ipv4Cidr::prefix_len mod nvx::net::cidr struct nvx::net::cidr::Cidr method nvx::net::cidr::Cidr::contains diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index d30cf2110..0c4b96d4d 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -676,15 +676,23 @@ fn nvx::host::artifacts::openvmm_executable mod nvx::host::cli (hidden) fn nvx::host::cli::main (hidden) mod nvx::host::config +struct nvx::host::config::Boot +field nvx::host::config::Boot::command_line enum nvx::host::config::ConsoleAttachment variant nvx::host::config::ConsoleAttachment::Disconnected variant nvx::host::config::ConsoleAttachment::Listener +const nvx::host::config::DEFAULT_GUEST_NETWORK struct nvx::host::config::LayeredRoot field nvx::host::config::LayeredRoot::format field nvx::host::config::LayeredRoot::gpt_first_partition_bound field nvx::host::config::LayeredRoot::image field nvx::host::config::LayeredRoot::overlay field nvx::host::config::LayeredRoot::upper +struct nvx::host::config::NetworkConfig +field nvx::host::config::NetworkConfig::guest_network +field nvx::host::config::NetworkConfig::policy +struct nvx::host::config::NicConfig +field nvx::host::config::NicConfig::guest_network enum nvx::host::config::RootConfig variant nvx::host::config::RootConfig::Blockless variant nvx::host::config::RootConfig::Layered @@ -697,10 +705,12 @@ variant nvx::host::config::UpperConfig::Scratch field nvx::host::config::UpperConfig::Scratch::journal field nvx::host::config::UpperConfig::Scratch::path struct nvx::host::config::VmConfig +field nvx::host::config::VmConfig::boot method nvx::host::config::VmConfig::check field nvx::host::config::VmConfig::console field nvx::host::config::VmConfig::filesystem field nvx::host::config::VmConfig::memory_mib +field nvx::host::config::VmConfig::network field nvx::host::config::VmConfig::processors field nvx::host::config::VmConfig::root field nvx::host::config::VmConfig::view @@ -2095,6 +2105,11 @@ method nvx::net::Cidr::is_ipv6 method nvx::net::Cidr::parse method nvx::net::Cidr::parse_masked method nvx::net::Cidr::subtract +struct nvx::net::Ipv4Cidr +method nvx::net::Ipv4Cidr::address +method nvx::net::Ipv4Cidr::new +method nvx::net::Ipv4Cidr::parse +method nvx::net::Ipv4Cidr::prefix_len mod nvx::net::cidr struct nvx::net::cidr::Cidr method nvx::net::cidr::Cidr::contains diff --git a/nvx-rs/src/host/config/mod.rs b/nvx-rs/src/host/config/mod.rs index d3a04ba59..9825e42e7 100644 --- a/nvx-rs/src/host/config/mod.rs +++ b/nvx-rs/src/host/config/mod.rs @@ -1,15 +1,22 @@ //! The configuration of a VM, as one value that [`VmConfig::check`] checks without reading the //! host: the VM's processors and memory, its [`RootConfig`], the [`View`] that its workload has -//! of it, the host paths that it exports, and its [`ConsoleAttachment`]. +//! of it, its [`NetworkConfig`], the host paths that it exports, its [`ConsoleAttachment`], and +//! what only its cold boot takes, a [`Boot`]. use std::ops::RangeInclusive; use std::path::PathBuf; use super::filesystem::{ExportPlan, ReservedTargets}; +use super::network::NetworkPolicy; use crate::activation::{self, Journal, MAX_HOSTNAME_BYTES, OverlayMode, View}; use crate::error::{Error, Result}; +use crate::net::Ipv4Cidr; use crate::protocol::root::{ImageFormat, RootImage}; +/// The guest address of a VM's NIC at a cold boot whose configuration names none, in the form +/// that [`Ipv4Cidr::parse`] reads. +pub const DEFAULT_GUEST_NETWORK: &str = "10.0.0.2/24"; + /// The caps of a RAM upper that [`VmConfig::check`] accepts, in percent of the VM's memory. const RAM_UPPER_CAPS: RangeInclusive = 1..=90; @@ -24,10 +31,15 @@ pub struct VmConfig { pub root: RootConfig, /// The view that the VM's workload has of it. pub view: View, + /// The VM's network, if it has a NIC. + pub network: Option, /// The host paths that the VM exports to its guest, if any. pub filesystem: Option, /// Where the output of the VM's boot console goes. pub console: ConsoleAttachment, + /// What only a cold boot takes: a VM that boots needs it, and one that a snapshot restores + /// takes none. + pub boot: Option, } /// The root of a VM. @@ -86,6 +98,30 @@ pub enum ConsoleAttachment { Disconnected, } +/// The network of a VM: the address of its NIC, and the policy that OpenVMM enforces on it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NetworkConfig { + /// The NIC's static address, if the caller names one. Without one, a cold boot takes + /// [`DEFAULT_GUEST_NETWORK`], and a restore the snapshot's. + pub guest_network: Option, + /// The policy that OpenVMM enforces on the VM's traffic. + pub policy: NetworkPolicy, +} + +/// The NIC that a VM has, with the static address that it settled on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct NicConfig { + /// The NIC's static address. + pub guest_network: Ipv4Cidr, +} + +/// What only a VM's cold boot takes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Boot { + /// The caller's own kernel command-line tokens, to which NVX adds those of what it lowers. + pub command_line: String, +} + impl VmConfig { /// Checks the whole configuration without reading the host: it opens no path, and checks no /// support of the host. @@ -96,6 +132,9 @@ impl VmConfig { /// without a path, or with a RAM upper whose cap is not 1 to 90 percent; a hostname that is /// empty, longer than [`MAX_HOSTNAME_BYTES`], or holds a NUL; and an export that /// [`ExportPlan::check`] refuses for the root, whose error is the source. + /// + /// The NIC's address is checked when it is built. The network's policy and the boot's command + /// line are checked where they are lowered, with the guest's address and NVX's own tokens. pub fn check(&self) -> Result<()> { if self.processors == 0 { return Err(Error::unsupported("a VM needs at least one processor")); diff --git a/nvx-rs/src/host/config/tests.rs b/nvx-rs/src/host/config/tests.rs index b92819e19..09f1f1dd5 100644 --- a/nvx-rs/src/host/config/tests.rs +++ b/nvx-rs/src/host/config/tests.rs @@ -7,6 +7,7 @@ use super::*; use crate::ErrorKind; use crate::activation::MachineIdMode; use crate::host::filesystem::{Bind, ExportChild, Owner}; +use crate::host::network::Access; use crate::uuid::Uuid; /// Returns the path of `name` in a directory that doesn't exist. @@ -24,8 +25,10 @@ fn blockless() -> VmConfig { hostname: b"box".to_vec(), machine_id: MachineIdMode::Image, }, + network: None, filesystem: None, console: ConsoleAttachment::Listener, + boot: None, } } @@ -105,6 +108,42 @@ fn blockless_and_layered_configurations_pass_with_either_console() { } } +#[test] +fn configurations_pass_with_or_without_a_nic_address_and_a_boot() { + let networks = [ + None, + Some(NetworkConfig { + guest_network: None, + policy: NetworkPolicy::new(Access::Deny), + }), + Some(NetworkConfig { + guest_network: Some(Ipv4Cidr::parse(DEFAULT_GUEST_NETWORK).unwrap()), + policy: NetworkPolicy::new(Access::Allow), + }), + ]; + let boots = [ + None, + Some(Boot { + command_line: String::new(), + }), + Some(Boot { + command_line: "quiet loglevel=0".to_owned(), + }), + ]; + for config in [blockless(), layered(erofs())] { + for network in &networks { + for boot in &boots { + let config = VmConfig { + network: network.clone(), + boot: boot.clone(), + ..config.clone() + }; + config.check().unwrap(); + } + } + } +} + #[test] fn a_ram_upper_holds_1_to_90_percent_of_the_memory_and_half_by_default() { assert_eq!(UpperConfig::DEFAULT_MAX_MEMORY_PERCENT, 50); diff --git a/nvx-rs/src/host/launch.rs b/nvx-rs/src/host/launch.rs index 7251455af..713fa1428 100644 --- a/nvx-rs/src/host/launch.rs +++ b/nvx-rs/src/host/launch.rs @@ -29,6 +29,8 @@ use crate::net::{is_valid_guest_network, is_valid_hostname}; use crate::protocol::WORKLOAD_ACCOUNT_TOKEN; use crate::protocol::cmdline::{self, Parameter}; +pub use super::config::DEFAULT_GUEST_NETWORK; + /// Bytes of the x86 kernel command line available to caller tokens, including the NUL. pub const COMMAND_LINE_BUDGET: usize = 1024; /// Longest Windows process command line, in UTF-16 code units, including the terminating NUL. @@ -49,8 +51,6 @@ pub const MAX_TIMEOUT: Duration = Duration::from_secs(30 * 24 * 60 * 60); pub const DEFAULT_MEMORY_MIB: u32 = 256; /// Default workload hostname. pub const DEFAULT_HOSTNAME: &str = "nvx-sandbox"; -/// Default guest network address. -pub const DEFAULT_GUEST_NETWORK: &str = "10.0.0.2/24"; /// The name of the kernel parameter that sets the workload hostname, which follows the extra /// kernel parameters on every command line. const HOSTNAME_PARAMETER: &str = "hostname"; diff --git a/nvx-rs/src/host/mod.rs b/nvx-rs/src/host/mod.rs index c03af0f9c..6594e7cd6 100644 --- a/nvx-rs/src/host/mod.rs +++ b/nvx-rs/src/host/mod.rs @@ -8,7 +8,8 @@ //! [`LaunchOptions`](runtime::LaunchOptions). //! - [`config`] holds the configuration of a VM as one value, a [`VmConfig`](config::VmConfig), //! which it checks without reading the host: the VM's processors and memory, its root, the view -//! that its workload has of it, its export, and its boot console. +//! that its workload has of it, its network, its export, its boot console, and what only its +//! cold boot takes. //! - [`launch`] builds OpenVMM's command line from a [`LaunchSpec`](launch::LaunchSpec): the //! managed lifecycle, the workload identity, the kernel command line, and the network and //! filesystem options. diff --git a/nvx-rs/src/lib.rs b/nvx-rs/src/lib.rs index 31684f719..90c2b4fdf 100644 --- a/nvx-rs/src/lib.rs +++ b/nvx-rs/src/lib.rs @@ -121,7 +121,7 @@ //! console without running a VM. It adds no API. //! - `cli`: NVX's own `nvx-rs` binary, the command-line interface that runs NVX's microVMs and //! sandboxes. It adds no API, and enables `host`. -//! - `serde`: the serde form of [`Hypervisor`]. +//! - `serde`: the serde forms of [`Hypervisor`] and [`net::Ipv4Cidr`]. //! - `guest`: the `guest` module and the guest binaries, which the `guest` profile builds as //! static `x86_64-unknown-linux-musl` executables. diff --git a/nvx-rs/src/net/mod.rs b/nvx-rs/src/net/mod.rs index 13e78edb8..51368f1a4 100644 --- a/nvx-rs/src/net/mod.rs +++ b/nvx-rs/src/net/mod.rs @@ -1,6 +1,9 @@ //! Network primitives that do not depend on the host: IP networks in CIDR notation and their //! subtraction, which egress rules use, the forms of a VM's hostname and guest network address, -//! and the gateway of a guest network. +//! a checked guest address, and the gateway of a guest network. + +use std::fmt; +use std::net::Ipv4Addr; pub mod cidr; @@ -22,28 +25,110 @@ pub fn is_valid_hostname(hostname: &str) -> bool { /// `ADDRESS/PREFIX` with a /1 to /30 prefix and an address other than the network's own, its /// broadcast address, and the gateway, which is its first address. pub fn is_valid_guest_network(value: &str) -> bool { - guest_address(value).is_some() + Ipv4Cidr::parse(value).is_ok() } /// Returns the gateway of a static guest address that [`is_valid_guest_network`] accepts: the /// first address of its network, at which OpenVMM presents the guest's gateway. Returns `None` /// for any other value. -pub fn guest_gateway(value: &str) -> Option { - let (address, mask) = guest_address(value)?; - Some(std::net::Ipv4Addr::from((address & mask) + 1)) -} - -/// Returns the address and the network mask of a static guest address that -/// [`is_valid_guest_network`] accepts. -fn guest_address(value: &str) -> Option<(u32, u32)> { - let (address, prefix_text) = value.split_once('/')?; - let address = address.parse::().ok()?; - let prefix = - cidr::parse_prefix_length(prefix_text).filter(|prefix| (1..=30).contains(prefix))?; - let mask = u32::MAX << (32 - u32::from(prefix)); - let host = u32::from(address); - let network = host & mask; - (![network, network | !mask, network + 1].contains(&host)).then_some((host, mask)) +pub fn guest_gateway(value: &str) -> Option { + let guest = Ipv4Cidr::parse(value).ok()?; + Some(Ipv4Addr::from( + (u32::from(guest.address) & guest.mask()) + 1, + )) +} + +/// A static guest address that OpenVMM accepts, checked when it is built: an IPv4 address with a +/// /1 to /30 prefix, which is not its network's own address, its broadcast address, or the +/// gateway, its network's first address. +/// +/// It displays and parses as `ADDRESS/PREFIX`, such as `10.0.0.2/24`, the form that +/// [`is_valid_guest_network`] accepts, and with the `serde` feature it serializes as that text. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Ipv4Cidr { + /// The guest's address. + address: Ipv4Addr, + /// The length of the network's prefix: 1 to 30. + prefix_len: u8, +} + +impl Ipv4Cidr { + /// Returns the guest address `address/prefix_len`. + /// + /// Fails with a message when the prefix is not /1 to /30, or when the address is its network's + /// own address, its broadcast address, or its gateway. + pub fn new(address: Ipv4Addr, prefix_len: u8) -> Result { + let guest = Self { + address, + prefix_len, + }; + let host = u32::from(address); + let valid = (1..=30).contains(&prefix_len) && { + let network = host & guest.mask(); + ![network, network | !guest.mask(), network + 1].contains(&host) + }; + if valid { + Ok(guest) + } else { + Err(invalid_guest_address(&guest.to_string())) + } + } + + /// Parses a guest address in the form `ADDRESS/PREFIX`, whose prefix is decimal digits without + /// a sign or leading zeros, as [`Ipv4Cidr::new`] checks it. + /// + /// Fails with a message that names `value` when it is not such an address. + pub fn parse(value: &str) -> Result { + let invalid = || invalid_guest_address(value); + let (address, prefix_len) = value.split_once('/').ok_or_else(invalid)?; + let address = address.parse().map_err(|_| invalid())?; + let prefix_len = cidr::parse_prefix_length(prefix_len).ok_or_else(invalid)?; + Self::new(address, prefix_len).map_err(|_| invalid()) + } + + /// Returns the address. + pub fn address(self) -> Ipv4Addr { + self.address + } + + /// Returns the prefix length: 1 to 30. + pub fn prefix_len(self) -> u8 { + self.prefix_len + } + + /// Returns the network mask of the prefix. + fn mask(self) -> u32 { + u32::MAX << (32 - u32::from(self.prefix_len)) + } +} + +impl fmt::Display for Ipv4Cidr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/{}", self.address, self.prefix_len) + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for Ipv4Cidr { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for Ipv4Cidr { + fn deserialize>(deserializer: D) -> Result { + let text = ::deserialize(deserializer)?; + Self::parse(&text).map_err(serde::de::Error::custom) + } +} + +/// Returns the message of a value that is not a guest address that OpenVMM accepts. +fn invalid_guest_address(value: &str) -> String { + format!( + "guest network {value:?} must be an IPv4 address with a /1 to /30 prefix that is not its \ + network's network, broadcast, or gateway (first) address" + ) } #[cfg(test)] diff --git a/nvx-rs/src/net/tests.rs b/nvx-rs/src/net/tests.rs index a48f1776e..aafaff770 100644 --- a/nvx-rs/src/net/tests.rs +++ b/nvx-rs/src/net/tests.rs @@ -43,3 +43,83 @@ fn hostnames_are_lowercase_labels() { assert!(!is_valid_hostname(invalid), "{invalid:?}"); } } + +#[test] +fn guest_addresses_keep_their_address_and_prefix_through_every_form() { + for (text, address, prefix_len) in [ + ("10.0.0.2/24", [10, 0, 0, 2], 24), + ("192.168.7.10/30", [192, 168, 7, 10], 30), + ("172.16.0.200/16", [172, 16, 0, 200], 16), + ("10.255.255.254/8", [10, 255, 255, 254], 8), + ("128.0.0.2/1", [128, 0, 0, 2], 1), + ] { + let address = std::net::Ipv4Addr::from(address); + let parsed = Ipv4Cidr::parse(text).unwrap(); + assert_eq!(parsed.address(), address, "{text}"); + assert_eq!(parsed.prefix_len(), prefix_len, "{text}"); + assert_eq!( + Ipv4Cidr::new(address, prefix_len).unwrap(), + parsed, + "{text}" + ); + assert_eq!(parsed.to_string(), text); + #[cfg(feature = "serde")] + { + let json = serde_json::to_string(&parsed).unwrap(); + assert_eq!(json, format!("\"{text}\"")); + assert_eq!(serde_json::from_str::(&json).unwrap(), parsed); + } + } +} + +#[test] +fn guest_addresses_refuse_what_openvmm_refuses() { + for text in [ + "10.0.0.2", + "10.0.0.2/", + "10.0.0.2/x", + "10.0.0.2/+24", + "10.0.0.2/024", + "10.0.0.2/0", + "10.0.0.2/31", + "10.0.0.2/32", + "/24", + "fd00::2/64", + "10.0.0.0/24", + "10.0.0.255/24", + "10.0.0.1/24", + "10.0.0.5/30", + ] { + let error = Ipv4Cidr::parse(text).unwrap_err(); + assert_eq!( + error, + format!( + "guest network {text:?} must be an IPv4 address with a /1 to /30 prefix that is \ + not its network's network, broadcast, or gateway (first) address" + ) + ); + #[cfg(feature = "serde")] + { + let error = serde_json::from_str::(&format!("\"{text}\"")).unwrap_err(); + assert!( + error.to_string().contains("must be an IPv4 address"), + "{error}" + ); + } + } + for (address, prefix_len) in [ + ([10, 0, 0, 2], 0), + ([10, 0, 0, 2], 31), + ([10, 0, 0, 2], 32), + ([10, 0, 0, 0], 24), + ([10, 0, 0, 255], 24), + ([10, 0, 0, 1], 24), + ] { + let error = Ipv4Cidr::new(std::net::Ipv4Addr::from(address), prefix_len).unwrap_err(); + let [a, b, c, d] = address; + assert!( + error.contains(&format!("\"{a}.{b}.{c}.{d}/{prefix_len}\"")), + "{error}" + ); + } +} From 2f6feb74a127f2b43a1592c087839dc6194f7ce6 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 17:03:01 -0800 Subject: [PATCH 09/29] guest: validate a bound before refusing its GPT image Read the root settings before the init refuses GPT images, so an invalid unsigned bound has the same numeric diagnostic in either token order. Keep GPT unsupported and pin the existing image-first overflow fixture's shell diagnostic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e354d65-494d-4fa9-ae9b-4f2e86c3c0f8 --- guest/common/nvx-init-agent | 15 +++++++++------ nvx-rs/tests/fixtures/root-tokens.json | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/guest/common/nvx-init-agent b/guest/common/nvx-init-agent index 15d6ea49d..164fabed5 100644 --- a/guest/common/nvx-init-agent +++ b/guest/common/nvx-init-agent @@ -178,11 +178,11 @@ is_canonical_uuid() { return 1 } -# Reads the value of the nvx_layer token for read_root_tokens: -# distro,0xd0003000,FORMAT[:IDENTITY], where this init accepts only the erofs -# format, with or without the filesystem UUID that the image must have. +# Reads the nvx_layer format and EROFS identity for read_root_tokens. +# This init assembles only EROFS; it refuses GPT after root-token validation. read_layer_token() { layer_image=${1#distro,0xd0003000,} + layer_format=${layer_image%%:*} case "$1" in distro,0xd0003000,*,*) ;; distro,0xd0003000,erofs) @@ -196,10 +196,9 @@ read_layer_token() { return 0 ;; distro,0xd0003000,gpt | distro,0xd0003000,gpt:*) - fatal "nvx_layer=$1 names a GPT image, which this init cannot assemble: it assembles only an EROFS image" + return 0 ;; distro,0xd0003000,*) - layer_format=${layer_image%%:*} # The form without a format names the UUID in its place. if [ -z "$layer_format" ] || is_canonical_uuid "$layer_format"; then fatal "the nvx_layer kernel parameter names no image format, erofs or gpt: '$1'" @@ -314,7 +313,11 @@ read_root_tokens() { fatal "the nvx_scratch_journal kernel parameter needs a scratch upper, nvx_scratch" [ -z "$ram_percent" ] || [ "$upper" = ram ] || fatal "the nvx_ram_upper_percent kernel parameter needs a RAM upper, nvx_overlay_upper" - case " $settings " in *" nvx_first_partition_bound "*) fatal "the nvx_first_partition_bound kernel parameter needs a GPT image" ;; esac + if [ "$layer_format" != gpt ]; then + case " $settings " in *" nvx_first_partition_bound "*) fatal "the nvx_first_partition_bound kernel parameter needs a GPT image" ;; esac + fi + [ "$layer_format" != gpt ] || + fatal "nvx_layer=distro,0xd0003000,$layer_image names a GPT image, which this init cannot assemble: it assembles only an EROFS image" [ "$overlay_mode" != untrusted-lowers ] || fatal "nvx_overlay_mode=untrusted-lowers names untrusted lowers, which this init cannot assemble: it mounts the overlay only for trusted lowers, with metacopy=on,xino=on" [ "$scratch_journal" != forbidden ] || diff --git a/nvx-rs/tests/fixtures/root-tokens.json b/nvx-rs/tests/fixtures/root-tokens.json index 229aa07b2..e7314963b 100644 --- a/nvx-rs/tests/fixtures/root-tokens.json +++ b/nvx-rs/tests/fixtures/root-tokens.json @@ -109,7 +109,7 @@ {"name": "a cap with an empty value", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not ''"}, {"name": "a bound of 0, before its image", "command_line": "nvx_first_partition_bound=0 nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '0'"}, {"name": "a hexadecimal bound, before its image", "command_line": "nvx_first_partition_bound=0x1000 nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '0x1000'"}, - {"name": "a bound past 64 bits", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4 nvx_first_partition_bound=18446744073709551616", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'"}, + {"name": "a bound past 64 bits", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4 nvx_first_partition_bound=18446744073709551616", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'", "shell_error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'"}, {"name": "a bound past 64 bits before an EROFS image", "command_line": "nvx_first_partition_bound=18446744073709551616 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'", "shell_error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'"}, {"name": "a 21 digit bound before an EROFS image", "command_line": "nvx_first_partition_bound=184467440737095516150 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '184467440737095516150'", "shell_error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '184467440737095516150'"}, {"name": "the largest bound before an EROFS image", "command_line": "nvx_first_partition_bound=18446744073709551615 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter needs a GPT image", "shell_error": "the nvx_first_partition_bound kernel parameter needs a GPT image"}, From e5fc81f259e11b3028b0a8faa02e9483d86ea9c2 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Wed, 4 Nov 2026 00:46:55 -0800 Subject: [PATCH 10/29] nvx-rs: carry the root's settings in its boot tokens, and cap a RAM upper The boot tokens of a layered root gain four optional settings, after the image's and the upper's: nvx_overlay_mode, trusted-lowers or untrusted-lowers; nvx_scratch_journal, allowed or forbidden; nvx_first_partition_bound, a GPT disk's bound in bytes; and nvx_ram_upper_percent, a RAM upper's cap from 1 to 90 percent of the VM's memory. RootLayout names each as an Option that renders no token when it is None, so that the guest keeps its default. RootLayout::parse reads them strictly: each once, with a value in its decimal or named form, only beside an image, and the journal policy only with a scratch upper, the cap with a RAM upper, and the bound with a GPT image. The shared fixture records the new cases, which nvx-rs and the shell init both replay. guest::root's Upper::Tmpfs takes the cap, mounts the tmpfs with size=N% when it has one, and refuses a cap outside 1 to 90 percent before anything changes, since the kernel reads 0 as no cap. NVX's shell init reads the four tokens with the parser's rules and messages, mounts a capped RAM upper with size=N%, and refuses, before it mounts anything, untrusted lowers and a forbidden journal, which it cannot assemble, as it refuses a GPT image. The sandbox plan names none of the settings, so its command lines stay as they were, and an adapter's plan refuses a setting that its command line names, since it reads the root's tokens back. A clone's activation assembles its tmpfs without a cap, since its record names none, and the runtime adapter's worker hands the cap of its boot tokens to its root. BOOT_CONTRACT_VERSION stays 5, since nothing deployed renders the new tokens. The public API snapshots list the new items. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e354d65-494d-4fa9-ae9b-4f2e86c3c0f8 --- .../4.1-guest-filesystem-and-image-model.md | 30 ++- guest/common/nvx-init-agent | 81 +++++- nvx-rs/README.md | 31 ++- nvx-rs/public-api/linux.txt | 11 + nvx-rs/public-api/windows.txt | 8 + nvx-rs/src/guest/clone/activation/apply.rs | 5 +- .../src/guest/clone/activation/apply/tests.rs | 12 +- nvx-rs/src/guest/root.rs | 53 +++- nvx-rs/src/guest/root/tests.rs | 122 ++++++--- nvx-rs/src/host/sandbox/plan.rs | 7 +- nvx-rs/src/host/sandbox/plan/tests.rs | 42 ++- nvx-rs/src/host/sandbox/tests.rs | 4 + nvx-rs/src/host/template/plan/tests.rs | 12 +- nvx-rs/src/protocol/root.rs | 248 ++++++++++++++--- nvx-rs/src/protocol/root/tests.rs | 255 ++++++++++++++++-- nvx-rs/tests/fixtures/root-tokens.json | 43 ++- scripts/test_nvx_tools.py | 132 ++++++++- tests/runtime-adapter/README.md | 8 +- tests/runtime-adapter/src/worker.rs | 14 +- .../src/worker/activation/tests.rs | 4 +- tests/runtime-adapter/tests/guest_session.rs | 10 +- tests/runtime-adapter/tests/layered_launch.rs | 4 + 22 files changed, 972 insertions(+), 164 deletions(-) diff --git a/doc/design/4.1-guest-filesystem-and-image-model.md b/doc/design/4.1-guest-filesystem-and-image-model.md index 4d64c77b5..3a7500bbf 100644 --- a/doc/design/4.1-guest-filesystem-and-image-model.md +++ b/doc/design/4.1-guest-filesystem-and-image-model.md @@ -45,8 +45,9 @@ stored and cached image bytes, because the guest kernel decompresses blocks as it reads them. No FUSE daemon or in-guest fetch service is involved in the root filesystem. -The launcher describes the root to the guest in two boot tokens, whose grammar -one contract module defines, with the host's renderer and a parser for guests: +The launcher describes the root to the guest in two boot tokens, and in up to +four more for its settings, whose grammar one contract module defines, with the +host's renderer and a parser for guests: - `nvx_layer=distro,0xd0003000,FORMAT[:IDENTITY]` names the image on the `distro` block device: its format, `erofs` for an EROFS filesystem or `gpt` @@ -56,13 +57,23 @@ one contract module defines, with the host's renderer and a parser for guests: - `nvx_scratch=0xd0006000,ext4` keeps the overlay's upper on the whole-device ext4 `scratch` disk, and `nvx_overlay_upper=ramfs` keeps it in guest memory, on a tmpfs, with no `scratch` block device. +- `nvx_overlay_mode=trusted-lowers` or `untrusted-lowers` names the overlay's + option set, `nvx_scratch_journal=allowed` or `forbidden` whether the + scratch's ext4 filesystem may have a journal, `nvx_first_partition_bound` + the most bytes of a GPT disk's first partition, and `nvx_ram_upper_percent` + the tmpfs upper's cap, from 1 to 90 percent of the guest's memory. A number + is decimal, without a sign or leading zeros. The guest takes its default for + a setting without a token: trusted lowers, a journal allowed, and the + kernel's default size of a tmpfs. Guests refuse a token without a value, a repeated token, both uppers, an image -without an upper or an upper without an image, a RAM upper beside a `scratch` -block device, and every other form, including the earlier `nvx_layer` form -without a format, rather than probe the devices. The guest agents described -here assemble only an EROFS image, over either upper: they refuse a GPT image -with a message that says so. +without an upper or an upper or a setting without an image, a setting of +another upper or of another format, a RAM upper beside a `scratch` block +device, and every other form, including the earlier `nvx_layer` form without a +format, rather than probe the devices. The guest agents described here +assemble only an EROFS image, over either upper, for trusted lowers, and don't +check a scratch for a journal: they refuse a GPT image, untrusted lowers, and +a forbidden journal with a message that says so. The guest agents assemble the root in this order: @@ -77,8 +88,9 @@ The guest agents assemble the root in this order: the token names one. 4. Mount the upper's file system, and create the overlay's upper and work directories on it: resolve and flush scratch and mount it as ext4 with - `rw,nosuid,nodev`, or mount a tmpfs with `mode=0755,nosuid,nodev` at the - kernel's default size, half of the guest's RAM. + `rw,nosuid,nodev`, or mount a tmpfs with `mode=0755,nosuid,nodev`, and + `size=N%` when `nvx_ram_upper_percent=N` caps it, or else at the kernel's + default size, half of the guest's RAM. 5. Mount the overlay with the `distro` layer as its lower directory, metadata-only copy-up, and extended inode numbers. 6. Verify the workload identity in the assembled root, mount each live share diff --git a/guest/common/nvx-init-agent b/guest/common/nvx-init-agent index dcad77a21..15d6ea49d 100644 --- a/guest/common/nvx-init-agent +++ b/guest/common/nvx-init-agent @@ -212,24 +212,36 @@ read_layer_token() { # Reads the boot tokens of the layered root, with the rules of the host's # renderer, nvx::protocol::root: nvx_layer names the image of the distro block -# device, and nvx_scratch=0xd0006000,ext4 or nvx_overlay_upper=ramfs the upper, -# each once. This init assembles only an EROFS image, so it also refuses a GPT -# image. Sets layer_uuid to the filesystem UUID that the image must have, or to -# nothing if the token names none, and upper to scratch or ram. The parser also -# refuses a RAM upper beside a scratch block device, which this init checks -# against the kernel's devices instead, with refuse_scratch_device. The tests -# replay nvx-rs/tests/fixtures/root-tokens.json through it. +# device, nvx_scratch=0xd0006000,ext4 or nvx_overlay_upper=ramfs the upper, and +# nvx_overlay_mode, nvx_scratch_journal, nvx_first_partition_bound, and +# nvx_ram_upper_percent the root's settings, each once. This init assembles +# only an EROFS image, mounts the overlay only for trusted lowers, and reads +# nothing of a scratch's superblock, so after those rules it refuses a GPT +# image, untrusted lowers, and a forbidden journal too. A bound is checked as +# an unsigned decimal even though it needs a GPT image, which this init cannot +# assemble. Sets layer_uuid to the filesystem UUID that the image must have, or to +# nothing if the token names none, upper to scratch or ram, and ram_percent to +# the RAM upper's cap, or to nothing for the kernel's default size. The parser +# also refuses a RAM upper beside a scratch block device, which this init +# checks against the kernel's devices instead, with refuse_scratch_device. The +# tests replay nvx-rs/tests/fixtures/root-tokens.json through it. read_root_tokens() { layer_seen=false scratch_seen=false ram_seen=false layer_uuid= + # The names of the settings that the command line names, and the values of + # three of them. + settings= + overlay_mode= + scratch_journal= + ram_percent= # Kernel command-line tokens are deliberately split on spaces. # shellcheck disable=SC2086 set -f for token in $cmdline; do case "$token" in - nvx_layer | nvx_scratch | nvx_overlay_upper) + nvx_layer | nvx_scratch | nvx_overlay_upper | nvx_overlay_mode | nvx_scratch_journal | nvx_first_partition_bound | nvx_ram_upper_percent) fatal "the $token kernel parameter has no value" ;; nvx_layer=*) @@ -252,6 +264,38 @@ read_root_tokens() { [ "${token#*=}" = ramfs ] || fatal "the nvx_overlay_upper kernel parameter must be ramfs, not '${token#*=}'" ;; + nvx_overlay_mode=* | nvx_scratch_journal=* | nvx_first_partition_bound=* | nvx_ram_upper_percent=*) + setting=${token%%=*} + case " $settings " in *" $setting "*) fatal "the $setting kernel parameter is repeated" ;; esac + settings="$settings $setting" + case "$token" in + nvx_overlay_mode=trusted-lowers | nvx_overlay_mode=untrusted-lowers) overlay_mode=${token#*=} ;; + nvx_overlay_mode=*) fatal "the $setting kernel parameter must be trusted-lowers or untrusted-lowers, not '${token#*=}'" ;; + nvx_scratch_journal=allowed | nvx_scratch_journal=forbidden) scratch_journal=${token#*=} ;; + nvx_scratch_journal=*) fatal "the $setting kernel parameter must be allowed or forbidden, not '${token#*=}'" ;; + nvx_ram_upper_percent=[1-9] | nvx_ram_upper_percent=[1-8][0-9] | nvx_ram_upper_percent=90) ram_percent=${token#*=} ;; + nvx_ram_upper_percent=*) fatal "the $setting kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '${token#*=}'" ;; + nvx_first_partition_bound= | nvx_first_partition_bound=0* | nvx_first_partition_bound=*[!0-9]*) fatal "the $setting kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '${token#*=}'" ;; + nvx_first_partition_bound=*) + bound_digits=${token#*=} + [ "${#bound_digits}" -le 20 ] || + fatal "the $setting kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '${token#*=}'" + if [ "${#bound_digits}" -eq 20 ]; then + bound_limit=18446744073709551615 + # Compare digits, never the full unsigned value. + while [ -n "$bound_digits" ]; do + bound_digit=${bound_digits%"${bound_digits#?}"} + limit_digit=${bound_limit%"${bound_limit#?}"} + [ "$bound_digit" -le "$limit_digit" ] || + fatal "the $setting kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '${token#*=}'" + [ "$bound_digit" -eq "$limit_digit" ] || break + bound_digits=${bound_digits#?} + bound_limit=${bound_limit#?} + done + fi + ;; + esac + ;; esac done set +f @@ -266,6 +310,15 @@ read_root_tokens() { else fatal "the nvx_layer kernel parameter needs an upper, nvx_scratch or nvx_overlay_upper" fi + [ -z "$scratch_journal" ] || [ "$upper" = scratch ] || + fatal "the nvx_scratch_journal kernel parameter needs a scratch upper, nvx_scratch" + [ -z "$ram_percent" ] || [ "$upper" = ram ] || + fatal "the nvx_ram_upper_percent kernel parameter needs a RAM upper, nvx_overlay_upper" + case " $settings " in *" nvx_first_partition_bound "*) fatal "the nvx_first_partition_bound kernel parameter needs a GPT image" ;; esac + [ "$overlay_mode" != untrusted-lowers ] || + fatal "nvx_overlay_mode=untrusted-lowers names untrusted lowers, which this init cannot assemble: it mounts the overlay only for trusted lowers, with metacopy=on,xino=on" + [ "$scratch_journal" != forbidden ] || + fatal "nvx_scratch_journal=forbidden forbids the scratch a journal, which this init cannot check: it reads nothing of the scratch's superblock" } # Fails if the kernel has a virtio-mmio device at the scratch block device's @@ -288,11 +341,11 @@ refuse_scratch_device() { # Mounts the upper that read_root_tokens read, and creates the overlay's upper # and work directories on it: the ext4 scratch disk at 0xd0006000, or a tmpfs, # with the mode of an ext4 scratch's root rather than tmpfs's world-writable -# default, and with the kernel's default size, half of the guest's RAM, as -# nvx::guest::root mounts them. Sets upper_point to the upper's mount point, -# and upper_name to what messages call it. A tmpfs upper's pages are charged to -# the memory cgroup of the process that writes them, so the workload's writes -# count against its nvx_memory_max. +# default, and with the size of its cap, size=N%, or else the kernel's default +# size, half of the guest's RAM, as nvx::guest::root mounts them. Sets +# upper_point to the upper's mount point, and upper_name to what messages call +# it. A tmpfs upper's pages are charged to the memory cgroup of the process +# that writes them, so the workload's writes count against its nvx_memory_max. mount_upper() { case "$upper" in scratch) @@ -310,7 +363,7 @@ mount_upper() { upper_point=$tmpfs upper_name="the tmpfs upper" mkdir -p "$upper_point" - mount -t tmpfs -o mode=0755,nosuid,nodev tmpfs "$upper_point" || + mount -t tmpfs -o "mode=0755,nosuid,nodev${ram_percent:+,size=$ram_percent%}" tmpfs "$upper_point" || fatal "failed to mount the tmpfs upper" ;; esac diff --git a/nvx-rs/README.md b/nvx-rs/README.md index e814382c9..106f9de8b 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -111,7 +111,7 @@ changes only when the module's purpose does. | [`nvx::protocol::cmdline`](#nvxprotocolcmdline) | The kernel command line as Linux and the guest's init read it, with the virtio-mmio devices and the guest's DNS server that it names | | [`nvx::protocol::export`](#nvxprotocolexport) | The boot tokens of the host export, the virtio-fs device from which a guest maps host paths, and the guest's parser of them | | [`nvx::protocol::nvxs`](#nvxprotocolnvxs) | The NVXS session, independent of any application protocol: its records and the state machines of its host and guest roles | -| [`nvx::protocol::root`](#nvxprotocolroot) | The boot tokens that tell the guest which image its layered root assembles and where the root keeps its upper | +| [`nvx::protocol::root`](#nvxprotocolroot) | The boot tokens that tell the guest which image its layered root assembles, where the root keeps its upper, and how the guest assembles them | | [`nvx::protocol::share`](#nvxprotocolshare) | The guest targets that a sandbox's live shares may not take, which the host and the guest both refuse | | [`nvx::protocol::status`](#nvxprotocolstatus) | The exit statuses with which a clone's guest ends its VM once it fails fast, and what each means | | [`nvx::activation`](#nvxactivation) | The activation record of a layered root, which a clone reads in place of its captured configuration, and what Ready reports | @@ -239,16 +239,25 @@ reassembles it. The boot tokens of a layered root, which the host renders and guests parse by the same rules: a `RootLayout` is a `RootImage` on the `distro` block device, an EROFS filesystem or a GPT disk with the `nvx::uuid::Uuid` that the guest expects -of it, if any, over a `RootUpper`, the ext4 scratch disk or RAM. +of it, if any, over a `RootUpper`, the ext4 scratch disk or RAM, with the +settings of the root's assembly that it names: the overlay's `OverlayMode` and +a scratch's `Journal` policy, both of `nvx::activation`, a GPT disk's +first-partition bound, and a RAM upper's cap, from 1 to 90 percent of the VM's +memory. The guest takes its own default for a setting that the layout leaves +out. `RootLayout::tokens` renders `nvx_layer=distro,0xd0003000,FORMAT[:IDENTITY]` and -`nvx_scratch=0xd0006000,ext4` or `nvx_overlay_upper=ramfs`, and +`nvx_scratch=0xd0006000,ext4` or `nvx_overlay_upper=ramfs`, then +`nvx_overlay_mode=trusted-lowers` or `untrusted-lowers`, +`nvx_scratch_journal=allowed` or `forbidden`, `nvx_first_partition_bound=BYTES`, +and `nvx_ram_upper_percent=PERCENT` for the settings that it names, and `RootLayout::parse` reads them from a kernel command line, and refuses -malformed, repeated, and conflicting tokens and the earlier form without a -format, with no fallback that probes the devices. It reads the virtio-mmio -devices of the command line with `nvx::protocol::cmdline`, to refuse a RAM upper -beside one at the scratch base. `tests/fixtures/root-tokens.json` holds its -cases, which the tests of the guest's shell init replay too. +malformed, repeated, and conflicting tokens, a setting of an upper or a format +that the layout lacks, and the earlier form without a format, with no fallback +that probes the devices. It reads the virtio-mmio devices of the command line +with `nvx::protocol::cmdline`, to refuse a RAM upper beside one at the scratch +base. `tests/fixtures/root-tokens.json` holds its cases, which the tests of the +guest's shell init replay too. ### `nvx::protocol::share` @@ -1633,8 +1642,10 @@ option set of the caller's `OverlayMode`, `metacopy=on,xino=on` or, for lowers that may carry untrusted overlay xattrs, `metacopy=off,redirect_dir=nofollow`, with no fallback, whose upper and work directories are on the `Upper`: an ext4 scratch mounted `nosuid,nodev`, whose superblock it checks for a journal that -the caller's `Journal` policy forbids, or a tmpfs mounted `nosuid,nodev` at the -kernel's default size. It refuses overlay options longer than +the caller's `Journal` policy forbids, or a tmpfs mounted `nosuid,nodev` with +`size=N%` for the caller's cap of N percent of the guest's memory, which it +refuses outside 1 to 90 before anything changes, or else at the kernel's +default size. It refuses overlay options longer than `MOUNT_OPTIONS_LIMIT`, the most that a mount passes, which, below `/run/nvx`, fit up to 195 layers of a GPT disk, and an overlay that the kernel's mount table shows read-only or without the mode's options in effect, since the kernel mounts diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 6be313fd4..e57b8d91c 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -1414,6 +1414,8 @@ field nvx::guest::root::Error::Size::actual (hidden) field nvx::guest::root::Error::Size::device (hidden) field nvx::guest::root::Error::Size::expected (hidden) field nvx::guest::root::Error::Size::role (hidden) +variant nvx::guest::root::Error::TmpfsCap (hidden) +field nvx::guest::root::Error::TmpfsCap::percent (hidden) variant nvx::guest::root::Error::Uuid (hidden) field nvx::guest::root::Error::Uuid::actual (hidden) field nvx::guest::root::Error::Uuid::expected (hidden) @@ -1525,6 +1527,7 @@ variant nvx::guest::root::Upper::Scratch (hidden) field nvx::guest::root::Upper::Scratch::device (hidden) field nvx::guest::root::Upper::Scratch::journal (hidden) variant nvx::guest::root::Upper::Tmpfs (hidden) +field nvx::guest::root::Upper::Tmpfs::max_memory_percent (hidden) method nvx::guest::root::Upper::kind (hidden) enum nvx::guest::root::UpperKind (hidden) variant nvx::guest::root::UpperKind::Scratch (hidden) @@ -3527,12 +3530,15 @@ fn nvx::protocol::nvxs::encode_record fn nvx::protocol::parse_host_mapping_count mod nvx::protocol::root const nvx::protocol::root::DISTRO_MMIO_BASE +const nvx::protocol::root::FIRST_PARTITION_BOUND_TOKEN enum nvx::protocol::root::ImageFormat variant nvx::protocol::root::ImageFormat::Erofs variant nvx::protocol::root::ImageFormat::Gpt method nvx::protocol::root::ImageFormat::as_str method nvx::protocol::root::ImageFormat::from_name const nvx::protocol::root::LAYER_TOKEN +const nvx::protocol::root::OVERLAY_MODE_TOKEN +const nvx::protocol::root::RAM_UPPER_PERCENT_TOKEN const nvx::protocol::root::RAM_UPPER_TOKEN struct nvx::protocol::root::RootImage method nvx::protocol::root::RootImage::erofs @@ -3540,7 +3546,11 @@ field nvx::protocol::root::RootImage::format method nvx::protocol::root::RootImage::gpt field nvx::protocol::root::RootImage::identity struct nvx::protocol::root::RootLayout +field nvx::protocol::root::RootLayout::gpt_first_partition_bound field nvx::protocol::root::RootLayout::image +field nvx::protocol::root::RootLayout::journal +field nvx::protocol::root::RootLayout::max_memory_percent +field nvx::protocol::root::RootLayout::overlay method nvx::protocol::root::RootLayout::parse method nvx::protocol::root::RootLayout::tokens field nvx::protocol::root::RootLayout::upper @@ -3548,6 +3558,7 @@ enum nvx::protocol::root::RootUpper variant nvx::protocol::root::RootUpper::Ram variant nvx::protocol::root::RootUpper::Scratch method nvx::protocol::root::RootUpper::as_str +const nvx::protocol::root::SCRATCH_JOURNAL_TOKEN const nvx::protocol::root::SCRATCH_MMIO_BASE const nvx::protocol::root::SCRATCH_TOKEN mod nvx::protocol::share diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index 0c4b96d4d..ac266137b 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -2452,12 +2452,15 @@ fn nvx::protocol::nvxs::encode_record fn nvx::protocol::parse_host_mapping_count mod nvx::protocol::root const nvx::protocol::root::DISTRO_MMIO_BASE +const nvx::protocol::root::FIRST_PARTITION_BOUND_TOKEN enum nvx::protocol::root::ImageFormat variant nvx::protocol::root::ImageFormat::Erofs variant nvx::protocol::root::ImageFormat::Gpt method nvx::protocol::root::ImageFormat::as_str method nvx::protocol::root::ImageFormat::from_name const nvx::protocol::root::LAYER_TOKEN +const nvx::protocol::root::OVERLAY_MODE_TOKEN +const nvx::protocol::root::RAM_UPPER_PERCENT_TOKEN const nvx::protocol::root::RAM_UPPER_TOKEN struct nvx::protocol::root::RootImage method nvx::protocol::root::RootImage::erofs @@ -2465,7 +2468,11 @@ field nvx::protocol::root::RootImage::format method nvx::protocol::root::RootImage::gpt field nvx::protocol::root::RootImage::identity struct nvx::protocol::root::RootLayout +field nvx::protocol::root::RootLayout::gpt_first_partition_bound field nvx::protocol::root::RootLayout::image +field nvx::protocol::root::RootLayout::journal +field nvx::protocol::root::RootLayout::max_memory_percent +field nvx::protocol::root::RootLayout::overlay method nvx::protocol::root::RootLayout::parse method nvx::protocol::root::RootLayout::tokens field nvx::protocol::root::RootLayout::upper @@ -2473,6 +2480,7 @@ enum nvx::protocol::root::RootUpper variant nvx::protocol::root::RootUpper::Ram variant nvx::protocol::root::RootUpper::Scratch method nvx::protocol::root::RootUpper::as_str +const nvx::protocol::root::SCRATCH_JOURNAL_TOKEN const nvx::protocol::root::SCRATCH_MMIO_BASE const nvx::protocol::root::SCRATCH_TOKEN mod nvx::protocol::share diff --git a/nvx-rs/src/guest/clone/activation/apply.rs b/nvx-rs/src/guest/clone/activation/apply.rs index 96247341b..ba919cea4 100644 --- a/nvx-rs/src/guest/clone/activation/apply.rs +++ b/nvx-rs/src/guest/clone/activation/apply.rs @@ -956,7 +956,10 @@ fn assembly(template: &RootTemplate, configuration: &Configuration) -> Assembly }, }; let upper = match configuration.upper { - activation::Upper::Tmpfs => root::Upper::Tmpfs, + // The record names no cap, so the tmpfs takes the kernel's default size. + activation::Upper::Tmpfs => root::Upper::Tmpfs { + max_memory_percent: None, + }, activation::Upper::Scratch { journal, .. } => root::Upper::Scratch { device: template.scratch, journal: match journal { diff --git a/nvx-rs/src/guest/clone/activation/apply/tests.rs b/nvx-rs/src/guest/clone/activation/apply/tests.rs index b3b02609d..a66827faf 100644 --- a/nvx-rs/src/guest/clone/activation/apply/tests.rs +++ b/nvx-rs/src/guest/clone/activation/apply/tests.rs @@ -632,7 +632,9 @@ fn accepted_assembly() -> Assembly { image: root::Image::Erofs { uuid: Some(IMAGE_UUID), }, - upper: root::Upper::Tmpfs, + upper: root::Upper::Tmpfs { + max_memory_percent: None, + }, overlay: root::OverlayMode::UntrustedLowers, timeout: template.timeout, } @@ -929,8 +931,14 @@ fn assembly_inputs_are_taken_exactly_from_the_record() { (gpt(None), gpt_image(None), true), (gpt(Some(DISK_GUID)), gpt_image(Some(DISK_GUID)), true), ]; + // The record names no cap of a tmpfs, which then takes the kernel's default size. let uppers = [ - (activation::Upper::Tmpfs, root::Upper::Tmpfs), + ( + activation::Upper::Tmpfs, + root::Upper::Tmpfs { + max_memory_percent: None, + }, + ), ( scratch(activation::Journal::Allowed), root::Upper::Scratch { diff --git a/nvx-rs/src/guest/root.rs b/nvx-rs/src/guest/root.rs index abebcdf72..dccf02b95 100644 --- a/nvx-rs/src/guest/root.rs +++ b/nvx-rs/src/guest/root.rs @@ -152,7 +152,7 @@ //! expected: Expected::new().disk_guid("8d2f3a4e-1c5b-4e7a-9f60-2b3c4d5e6f70".parse()?), //! first_partition_bound: 64 * 1024, //! }, -//! upper: Upper::Tmpfs, +//! upper: Upper::Tmpfs { max_memory_percent: Some(50) }, //! overlay: OverlayMode::UntrustedLowers, //! timeout: Duration::from_secs(30), //! })?; @@ -194,6 +194,7 @@ use super::gpt::DeviceError; use super::mappings::{Kind, Mapper, MappingError, TargetRoot, below_its_root}; use super::sys::{self, Errno, FileDescriptor, FileSystem, Mode, MountFlags, OpenFlags}; use crate::gpt::{Expected, GptError, Partition}; +use crate::protocol::root::RAM_UPPER_CAPS; use crate::uuid::Uuid; pub(crate) use self::block::window_directories; @@ -235,7 +236,8 @@ const WORK_DIRECTORY: &str = "work"; /// root directory takes the upper's mode, and a workload that is not root must enter its root. const SCRATCH_DIRECTORY_MODE: u32 = 0o755; /// The tmpfs upper's options: the mode of its root directory, which an ext4 scratch's root has -/// too, rather than tmpfs's world-writable default. Its size is the kernel's default. +/// too, rather than tmpfs's world-writable default. An assembly adds the size of a tmpfs that has +/// a cap, `size=N%`, and the kernel's default size is that of one that has none. const TMPFS_OPTIONS: &[u8] = b"mode=0755"; /// Do not load a GPT layer's journal: even without `needs_recovery`, loading can scan a corrupt /// journal indefinitely. `check_layer` still refuses recovery-required layers. Override the @@ -411,9 +413,13 @@ pub enum Upper { /// Whether the file system may have a journal. journal: Journal, }, - /// A tmpfs at the kernel's default size, which an assembly mounts `nosuid,nodev` at `tmpfs`, - /// for a VM that has no scratch device. - Tmpfs, + /// A tmpfs, which an assembly mounts `nosuid,nodev` at `tmpfs`, for a VM that has no scratch + /// device. + Tmpfs { + /// The tmpfs's cap, in percent of the guest's memory, from 1 to 90, which an assembly + /// mounts it with as `size=N%`, or `None` for the kernel's default size, half of it. + max_memory_percent: Option, + }, } impl Upper { @@ -421,7 +427,7 @@ impl Upper { pub const fn kind(self) -> UpperKind { match self { Self::Scratch { .. } => UpperKind::Scratch, - Self::Tmpfs => UpperKind::Tmpfs, + Self::Tmpfs { .. } => UpperKind::Tmpfs, } } } @@ -477,7 +483,8 @@ impl OverlayMode { /// Assembles the layered root of `assembly`, as the [module](self) describes, and returns the /// root that owns its mounts. It checks everything before it mounts anything. In order, it: /// -/// 1. checks the runtime directory's path; +/// 1. checks the runtime directory's path, and that a tmpfs upper's cap, if any, is from 1 to 90 +/// percent of the guest's memory, since the kernel reads a cap of 0 as none; /// 2. creates the runtime directory, as `mkdir -p` does, and refuses it if a symbolic link leads /// through it; /// 3. creates the mount points of an EROFS image, `layers/distro`, of the upper, `scratch` or @@ -504,7 +511,7 @@ impl OverlayMode { /// 8. mounts the layers: an EROFS image read-only, or a GPT disk's ext4 layers read-only with /// `nosuid` and `nodev`; /// 9. mounts the upper with `nosuid` and `nodev`: the scratch's ext4 file system, or a tmpfs -/// whose root directory has mode 0755; +/// whose root directory has mode 0755, with the size of its cap, if any; /// 10. creates the upper's `upper` and `work` directories unless they exist, with mode 0755 /// whatever the process's umask, since the overlay's root directory takes the upper's mode; /// 11. mounts the overlay at `rootfs`, `nosuid` and `nodev`, with the layers as its lower @@ -616,6 +623,16 @@ fn assemble_with( cleanup: None, }); } + if let Upper::Tmpfs { + max_memory_percent: Some(percent), + } = assembly.upper + && !RAM_UPPER_CAPS.contains(&percent) + { + return Err(AssemblyError { + error: Error::TmpfsCap { percent }, + cleanup: None, + }); + } let mut root = Root { kernel, runtime: assembly.runtime.clone(), @@ -1101,7 +1118,7 @@ impl Root { } Some(device) } - Upper::Tmpfs => None, + Upper::Tmpfs { .. } => None, }; let upper = upper_point.join(UPPER_DIRECTORY); let work = upper_point.join(WORK_DIRECTORY); @@ -1140,13 +1157,20 @@ impl Root { Mount::Scratch } None => { + let mut options = TMPFS_OPTIONS.to_vec(); + if let Upper::Tmpfs { + max_memory_percent: Some(percent), + } = assembly.upper + { + options.extend(format!(",size={percent}%").bytes()); + } self.mount_at( &Mount::Tmpfs, Some(b"tmpfs"), &upper_point, Some(b"tmpfs"), flags, - Some(TMPFS_OPTIONS), + Some(&options), )?; Mount::Tmpfs } @@ -1832,6 +1856,11 @@ pub enum Error { /// The runtime directory. path: PathBuf, }, + /// A tmpfs upper's cap is not 1 to 90 percent of the guest's memory. + TmpfsCap { + /// The cap, in percent of the guest's memory. + percent: u8, + }, /// A mount point or a directory below one could not be created or checked for a mount, or /// the runtime directory could not be locked. MountPoint { @@ -2029,6 +2058,10 @@ impl fmt::Display for Error { `=`, or a symbolic link: {}", path.display() ), + Self::TmpfsCap { percent } => write!( + formatter, + "the tmpfs upper's cap of {percent}% of the guest's memory is not 1 to 90%" + ), Self::MountPoint { path, error } => { write!(formatter, "failed to prepare {}: {error}", path.display()) } diff --git a/nvx-rs/src/guest/root/tests.rs b/nvx-rs/src/guest/root/tests.rs index 8b3fdf851..f116c2cc9 100644 --- a/nvx-rs/src/guest/root/tests.rs +++ b/nvx-rs/src/guest/root/tests.rs @@ -225,6 +225,11 @@ fn upper_point(kind: UpperKind) -> &'static str { } } +/// The tmpfs upper without a cap, at the kernel's default size. +const TMPFS: Upper = Upper::Tmpfs { + max_memory_percent: None, +}; + /// Returns the upper on the guest's scratch device, with `journal`. fn scratch_upper(journal: Journal) -> Upper { Upper::Scratch { @@ -946,7 +951,7 @@ fn new_upper_and_work_directories_have_mode_0755_whatever_the_umask() { // So are a tmpfs upper's, which are always new. let guest = Guest::new(); let assembly = Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, ..guest.assembly() }; guest.assemble(&assembly).unwrap().teardown().unwrap(); @@ -959,7 +964,7 @@ fn a_fresh_upper_s_directories_must_be_missing_or_empty() { let mode = |path: &Path| fs::metadata(path).unwrap().mode() & 0o7777; // A scratch or a tmpfs without the directories gets them, as any root's upper does, with no // other call than a cold root's. - for upper in [scratch_upper(Journal::Allowed), Upper::Tmpfs] { + for upper in [scratch_upper(Journal::Allowed), TMPFS] { let guest = Guest::new(); let assembly = Assembly { upper, @@ -1272,8 +1277,8 @@ fn a_runtime_directory_where_a_file_system_is_mounted_is_refused() { // So does an upper that a failed unmount left, whichever upper the next root mounts. for (first, second) in [ - (scratch_upper(Journal::Allowed), Upper::Tmpfs), - (Upper::Tmpfs, scratch_upper(Journal::Allowed)), + (scratch_upper(Journal::Allowed), TMPFS), + (TMPFS, scratch_upper(Journal::Allowed)), ] { let guest = Guest::new(); let left = guest.path(&format!("run/nvx/{}", upper_point(first.kind()))); @@ -1586,8 +1591,25 @@ fn a_gpt_disk_s_layers_are_mounted_under_the_overlay_top_first() { #[test] fn both_overlay_modes_assemble_on_both_uppers_with_their_own_option_sets() { let modes = [OverlayMode::TrustedLowers, OverlayMode::UntrustedLowers]; + // Each upper with the options of its tmpfs, if it is one: a tmpfs with a cap takes its size. + let uppers = [ + (scratch_upper(Journal::Allowed), None), + (TMPFS, Some("mode=0755")), + ( + Upper::Tmpfs { + max_memory_percent: Some(1), + }, + Some("mode=0755,size=1%"), + ), + ( + Upper::Tmpfs { + max_memory_percent: Some(90), + }, + Some("mode=0755,size=90%"), + ), + ]; for gpt in [false, true] { - for upper in [scratch_upper(Journal::Allowed), Upper::Tmpfs] { + for (upper, tmpfs_options) in uppers { for mode in modes { let case = format!("GPT {gpt}, {upper:?}, {mode:?}"); let guest = Guest::new(); @@ -1627,7 +1649,7 @@ fn both_overlay_modes_assemble_on_both_uppers_with_their_own_option_sets() { &format!("{runtime}/tmpfs"), Some("tmpfs"), flags, - Some("mode=0755"), + tmpfs_options, ), ), }; @@ -1677,13 +1699,24 @@ fn a_tmpfs_upper_needs_no_scratch_device() { let guest = Guest::bare(); attach(&guest.base, 0, "vda", 4096); let assembly = Assembly { - upper: Upper::Tmpfs, + upper: Upper::Tmpfs { + max_memory_percent: Some(50), + }, ..guest.assembly() }; let root = guest.assemble(&assembly).unwrap(); // The assembly waited for no other device, and made no mount point for a scratch. assert_eq!(guest.kernel.sleeps(), 0); assert!(!guest.path("run/nvx/scratch").exists()); + // It mounted the tmpfs with its cap. + let tmpfs = mount_call( + Some("tmpfs"), + &guest.text("run/nvx/tmpfs"), + Some("tmpfs"), + MountFlags::MS_NOSUID | MountFlags::MS_NODEV, + Some("mode=0755,size=50%"), + ); + assert!(guest.kernel.calls().contains(&tmpfs)); root.teardown().unwrap(); assert_eq!( guest.kernel.unmounts(), @@ -1694,10 +1727,37 @@ fn a_tmpfs_upper_needs_no_scratch_device() { ); } +#[test] +fn a_tmpfs_upper_refuses_a_cap_that_is_not_1_to_90_percent() { + // The kernel reads a cap of 0 as none, and the boot tokens carry caps from 1 to 90 percent. + let guest = Guest::new(); + for percent in [0, 91, u8::MAX] { + let assembly = Assembly { + upper: Upper::Tmpfs { + max_memory_percent: Some(percent), + }, + ..guest.assembly() + }; + let error = guest.assemble(&assembly).unwrap_err(); + assert!( + matches!(error.error(), Error::TmpfsCap { percent: cap } if *cap == percent), + "{percent}: {error:?}" + ); + assert!(error.cleanup().is_none()); + assert_eq!( + error.to_string(), + format!("the tmpfs upper's cap of {percent}% of the guest's memory is not 1 to 90%") + ); + } + // It refused them before it changed anything. + assert!(guest.kernel.calls().is_empty()); + assert!(!guest.path("run/nvx/layers").exists()); +} + #[test] fn a_refused_option_set_fails_without_a_fallback() { for mode in [OverlayMode::TrustedLowers, OverlayMode::UntrustedLowers] { - for upper in [scratch_upper(Journal::Allowed), Upper::Tmpfs] { + for upper in [scratch_upper(Journal::Allowed), TMPFS] { let guest = Guest::new(); guest.kernel.fail( Operation::Mount, @@ -1753,7 +1813,7 @@ fn an_overlay_that_the_kernel_mounts_without_its_mode_in_effect_is_refused_and_u // Without failing the mount, the kernel drops `metacopy=on` for an upper that can't hold the // overlay's extended attributes. Its mount table then shows no `metacopy`, which is off by // default. - for upper in [scratch_upper(Journal::Allowed), Upper::Tmpfs] { + for upper in [scratch_upper(Journal::Allowed), TMPFS] { let guest = Guest::new(); guest.show_overlay("rw", upper.kind(), "xino=on"); let assembly = Assembly { @@ -1810,7 +1870,7 @@ fn options_that_the_kernel_does_not_show_have_their_defaults() { // The kernel's mount table shows no option at its default, as for each option of the // untrusted lowers under the guest's kernel, whose defaults are off. let untrusted = |guest: &Guest| Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, overlay: OverlayMode::UntrustedLowers, ..guest.assembly() }; @@ -1905,7 +1965,7 @@ fn effective_options_that_cannot_be_read_fail_the_assembly() { } guest.show_overlay("rw", UpperKind::Tmpfs, ""); let assembly = Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, overlay: OverlayMode::UntrustedLowers, ..guest.assembly() }; @@ -1953,7 +2013,7 @@ fn overlay_options_that_a_mount_cannot_pass_are_refused_before_any_mount() { let runtime = guest.path(&format!("run/{}/nvx", [component.as_str(); 6].join("/"))); let assembly = Assembly { runtime: runtime.clone(), - upper: Upper::Tmpfs, + upper: TMPFS, ..guest.assembly() }; let error = guest.assemble(&assembly).unwrap_err(); @@ -2006,7 +2066,7 @@ fn up_to_195_layers_fit_in_the_overlay_s_options_below_run_nvx() { guest.insert(&GptDisk::new(b"configuration", 254)); let mode = OverlayMode::UntrustedLowers; let error = guest - .assemble(&guest.gpt_assembly(Upper::Tmpfs, mode)) + .assemble(&guest.gpt_assembly(TMPFS, mode)) .unwrap_err(); let layers: Vec = (2..=255) .rev() @@ -2165,7 +2225,7 @@ fn a_device_whose_access_mode_is_not_its_role_s_is_refused_before_it_is_flushed( fn a_tmpfs_upper_is_unmounted_last_and_every_failure_reported() { let guest = Guest::new(); let assembly = Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, overlay: OverlayMode::UntrustedLowers, ..guest.assembly() }; @@ -2226,7 +2286,7 @@ fn a_scratch_s_file_system_is_synced_right_before_its_unmount() { // A tmpfs upper has no device to sync to. let guest = Guest::new(); let assembly = Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, ..guest.assembly() }; let root = guest.assemble(&assembly).unwrap(); @@ -2285,7 +2345,7 @@ fn a_gpt_disk_that_differs_from_the_expected_identities_is_refused_before_any_mo // The disk meets every expectation that the caller may set. let guest = Guest::new(); guest.insert(&disk); - let mut assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::UntrustedLowers); + let mut assembly = guest.gpt_assembly(TMPFS, OverlayMode::UntrustedLowers); assembly.image = Image::Gpt { expected: Expected::new() .disk_guid(DISK_GUID) @@ -2338,7 +2398,7 @@ fn a_gpt_disk_that_differs_from_the_expected_identities_is_refused_before_any_mo for (expected, failure) in cases { let guest = Guest::new(); guest.insert(&disk); - let mut assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::UntrustedLowers); + let mut assembly = guest.gpt_assembly(TMPFS, OverlayMode::UntrustedLowers); assembly.image = Image::Gpt { expected, first_partition_bound: FIRST_PARTITION_BOUND, @@ -2365,7 +2425,7 @@ fn a_gpt_disk_that_differs_from_the_expected_identities_is_refused_before_any_mo fn a_gpt_disk_without_a_layer_is_refused_before_any_mount() { let guest = Guest::new(); guest.insert(&GptDisk::new(b"configuration", 0)); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!( @@ -2396,7 +2456,7 @@ fn a_layer_that_is_not_ext4_or_whose_journal_needs_a_recovery_is_refused_before_ disk.layer_mut(2)[1024 + 0x5C] |= 0x04; let guest = Guest::new(); guest.insert(&disk); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); guest.assemble(&assembly).unwrap().teardown().unwrap(); // But not one that needs recovery, despite our `noload` or the superblock's own defaults. @@ -2405,7 +2465,7 @@ fn a_layer_that_is_not_ext4_or_whose_journal_needs_a_recovery_is_refused_before_ layer[1024 + 0x200..][..10].copy_from_slice(b"norecovery"); let guest = Guest::new(); guest.insert(&disk); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!(error.error(), Error::Recovery { number: 3, device } @@ -2427,7 +2487,7 @@ fn a_layer_that_is_not_ext4_or_whose_journal_needs_a_recovery_is_refused_before_ disk.layer_mut(2)[1024 + 0x38] ^= 1; let guest = Guest::new(); guest.insert(&disk); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!(error.error(), Error::NotExt4Layer { number: 2, device } @@ -2448,7 +2508,7 @@ fn a_layer_that_is_not_ext4_or_whose_journal_needs_a_recovery_is_refused_before_ let guest = Guest::new(); fs::write(guest.path("dev/vda"), disk.image()).unwrap(); fs::create_dir(guest.path("dev/vda2")).unwrap(); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!( @@ -2474,7 +2534,7 @@ fn a_missing_partition_fails_at_once_before_any_mount() { let guest = Guest::new(); fs::write(guest.path("dev/vda"), disk.image()).unwrap(); write(&guest.path("dev/vda2"), disk.layer(2)); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!(error.error(), Error::Partition { number: 3, node } @@ -2748,7 +2808,7 @@ fn the_first_partition_is_returned_as_opaque_bytes_within_its_bound() { let guest = Guest::new(); let disk = GptDisk::new(bytes, 1); guest.insert(&disk); - let mut assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let mut assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); // The first partition may be as large as the bound. assembly.image = Image::Gpt { expected: Expected::new(), @@ -2766,7 +2826,7 @@ fn the_first_partition_is_returned_as_opaque_bytes_within_its_bound() { // A first partition larger than the bound is refused before any mount. let guest = Guest::new(); guest.insert(&GptDisk::new(§or, 1)); - let mut assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let mut assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); assembly.image = Image::Gpt { expected: Expected::new(), first_partition_bound: BLOCK - 1, @@ -2800,7 +2860,7 @@ fn the_first_partition_is_returned_as_opaque_bytes_within_its_bound() { fn a_teardown_unmounts_the_layers_top_first_and_reports_every_failure() { let guest = Guest::new(); guest.insert(&GptDisk::new(b"configuration", 3)); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::UntrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::UntrustedLowers); let root = guest.assemble(&assembly).unwrap(); for name in ["run/nvx/layers/p3", "run/nvx/tmpfs"] { guest @@ -2846,7 +2906,7 @@ fn a_teardown_unmounts_the_layers_top_first_and_reports_every_failure() { guest.path("run/nvx/layers/p3"), Errno::EINVAL, ); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::UntrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::UntrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!( @@ -2872,12 +2932,12 @@ fn a_layer_that_a_failed_unmount_left_keeps_the_runtime_directory_occupied() { guest .kernel .fail(Operation::Unmount, top.clone(), Errno::EBUSY); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); guest.assemble(&assembly).unwrap().teardown().unwrap_err(); guest.insert(&GptDisk::new(b"configuration", 1)); let (mounts, calls) = (guest.kernel.mounts().len(), guest.kernel.calls()); let erofs = Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, ..guest.assembly() }; for assembly in [erofs, assembly] { @@ -2897,13 +2957,13 @@ fn a_layer_that_a_failed_unmount_left_keeps_the_runtime_directory_occupied() { .kernel .fail(Operation::Unmount, distro.clone(), Errno::EBUSY); let assembly = Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, ..guest.assembly() }; guest.assemble(&assembly).unwrap().teardown().unwrap_err(); guest.insert(&GptDisk::new(b"configuration", 1)); let (mounts, calls) = (guest.kernel.mounts().len(), guest.kernel.calls()); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!(error.error(), Error::Occupied { path } if *path == distro), diff --git a/nvx-rs/src/host/sandbox/plan.rs b/nvx-rs/src/host/sandbox/plan.rs index d7f96196f..6e7ab5cd7 100644 --- a/nvx-rs/src/host/sandbox/plan.rs +++ b/nvx-rs/src/host/sandbox/plan.rs @@ -495,11 +495,16 @@ impl SandboxPlan { self.network.as_ref().and_then(SandboxNetwork::proxy_url) } - /// Returns the layout of the root that the plan boots: its image and its upper. + /// Returns the layout of the root that the plan boots: its image and its upper. It names none + /// of the root's settings, so the guest assembles the root with its own defaults. pub fn layout(&self) -> RootLayout { RootLayout { image: self.distro.image, upper: self.upper.kind(), + overlay: None, + journal: None, + gpt_first_partition_bound: None, + max_memory_percent: None, } } diff --git a/nvx-rs/src/host/sandbox/plan/tests.rs b/nvx-rs/src/host/sandbox/plan/tests.rs index da35e54d6..3e1d16a82 100644 --- a/nvx-rs/src/host/sandbox/plan/tests.rs +++ b/nvx-rs/src/host/sandbox/plan/tests.rs @@ -172,11 +172,16 @@ fn every_format_and_upper_plans_and_renders() { for (image, upper, tokens) in cases { let (image, upper) = (images()[image], uppers()[upper].clone()); let plan = plan(image, upper.clone(), settings.clone()); + // The plan names none of the root's settings. assert_eq!( plan.layout(), RootLayout { image, - upper: upper.kind() + upper: upper.kind(), + overlay: None, + journal: None, + gpt_first_partition_bound: None, + max_memory_percent: None, } ); assert_eq!( @@ -1367,15 +1372,12 @@ fn adapter_plans_render_their_root_and_devices_alone() { let plan = adapter(image, upper.clone()); assert_eq!(plan.settings(), None); assert_eq!(plan.export(), None); - let [layer, upper_token] = plan.layout().tokens(); + let tokens = plan.layout().tokens().join(" "); assert_eq!( plan.kernel_command_line(user).unwrap(), - format!("{user} {layer} {upper_token}") - ); - assert_eq!( - plan.kernel_command_line(" \t").unwrap(), - format!("{layer} {upper_token}") + format!("{user} {tokens}") ); + assert_eq!(plan.kernel_command_line(" \t").unwrap(), tokens); // No workload identity and no shares: the block devices alone. let mut expected = vec![ "--machine".to_owned(), @@ -1467,6 +1469,32 @@ fn adapter_plans_refuse_what_their_root_and_openvmm_own() { assert_eq!(plan.clone().with_export(None).unwrap(), plan); } +#[test] +fn adapter_plans_refuse_the_settings_of_their_root_that_they_do_not_name() { + // An adapter's plan names none of its root's settings, and the guest reads a setting from the + // command line as the plan reads it back, so the plan refuses a setting that the adapter's + // command line names. + let ram = adapter(images()[3], SandboxUpper::RAM); + let scratch = adapter(images()[1], SandboxUpper::scratch("scratch.ext4")); + let hides = "the kernel command line of an adapter's plan hides the boot tokens of its root"; + for (plan, user, refusal) in [ + (&ram, "nvx_overlay_mode=untrusted-lowers", hides), + (&ram, "nvx_ram_upper_percent=5", hides), + (&ram, "x=\"y nvx_ram_upper_percent=5 z\"", hides), + (&ram, "nvx_first_partition_bound=4096", hides), + (&scratch, "nvx_scratch_journal=forbidden", hides), + ( + &scratch, + "nvx_ram_upper_percent=0", + "the kernel command line of an adapter's plan conflicts with the boot tokens of its \ + root: the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to \ + 90, without a sign or leading zeros, not '0'", + ), + ] { + assert_eq!(message(plan.kernel_command_line(user)), refusal, "{user}"); + } +} + #[test] fn plans_refuse_the_tokens_that_the_guest_init_reads_in_quoted_values() { // The kernel reads `x` with a quoted value, but the guest's init splits the command line at diff --git a/nvx-rs/src/host/sandbox/tests.rs b/nvx-rs/src/host/sandbox/tests.rs index 06255e714..cc4a999df 100644 --- a/nvx-rs/src/host/sandbox/tests.rs +++ b/nvx-rs/src/host/sandbox/tests.rs @@ -166,6 +166,10 @@ fn launch_contract_builds_agent_command_line() { RootLayout { image: RootImage::erofs(Uuid::parse("11111111-1111-1111-1111-111111111111")), upper: RootUpper::Scratch, + overlay: None, + journal: None, + gpt_first_partition_bound: None, + max_memory_percent: None, } ); assert_eq!( diff --git a/nvx-rs/src/host/template/plan/tests.rs b/nvx-rs/src/host/template/plan/tests.rs index f0cc4ef5f..3144eb883 100644 --- a/nvx-rs/src/host/template/plan/tests.rs +++ b/nvx-rs/src/host/template/plan/tests.rs @@ -547,14 +547,20 @@ fn openvmms_platform_allowlist_admits_its_tokens_and_refuses_tenant_data() { } // The tokens of a root, a workload, a share, a listener, and anything else that a tenant's // sandbox or another tier sets. - let [layer, upper] = adapter(None).layout().tokens(); - let [_, ram] = adapter_with(gpt(), SandboxUpper::RAM, None) + let [layer, upper]: [String; 2] = adapter(None).layout().tokens().try_into().unwrap(); + let [_, ram]: [String; 2] = adapter_with(gpt(), SandboxUpper::RAM, None) .layout() - .tokens(); + .tokens() + .try_into() + .unwrap(); for token in [ layer.as_str(), upper.as_str(), ram.as_str(), + "nvx_overlay_mode=untrusted-lowers", + "nvx_scratch_journal=forbidden", + "nvx_first_partition_bound=4096", + "nvx_ram_upper_percent=50", "nvx_hostname=tenant-a", "hostname=tenant-a", "nvx_entrypoint=/bin/sh", diff --git a/nvx-rs/src/protocol/root.rs b/nvx-rs/src/protocol/root.rs index 88ced8b88..a02f976b9 100644 --- a/nvx-rs/src/protocol/root.rs +++ b/nvx-rs/src/protocol/root.rs @@ -1,5 +1,6 @@ //! The boot tokens of a layered root: the kernel command-line tokens through which the host tells -//! the guest which image its layered root assembles, and where the root keeps its upper. +//! the guest which image its layered root assembles, where the root keeps its upper, and how the +//! guest assembles them. //! //! A layered root overlays one read-only image, on the `distro` block device, with a writable //! upper. Two tokens describe it, which [`RootLayout::tokens`] renders: @@ -15,20 +16,38 @@ //! [`SCRATCH_MMIO_BASE`], which the microVM fixes for their roles, and identities take the //! canonical text of a [`Uuid`]. //! +//! Four more tokens each name a setting of the root, after those two and in this order, when the +//! layout names it. Without one, the guest assembles the root with its own default, as it does for +//! a command line that predates them: +//! +//! - `nvx_overlay_mode=trusted-lowers` or `nvx_overlay_mode=untrusted-lowers` names the overlay's +//! [`OverlayMode`]; the guest's default trusts the lowers. +//! - `nvx_scratch_journal=allowed` or `nvx_scratch_journal=forbidden` names whether the ext4 +//! filesystem of a scratch upper may have a journal, its [`Journal`] policy; the guest's default +//! allows one. +//! - `nvx_first_partition_bound=BYTES` bounds the bytes of a GPT disk's first partition. +//! - `nvx_ram_upper_percent=PERCENT` caps a RAM upper at PERCENT percent of the VM's memory, from 1 +//! to 90; the guest's default is the kernel's default size of a tmpfs, half of its memory. +//! +//! A number is decimal, at least 1, without a sign or leading zeros. The journal policy needs a +//! scratch upper, the cap a RAM upper, and the bound a GPT disk. +//! //! Guests read the tokens with [`RootLayout::parse`], which accepts exactly these forms and //! refuses every other, with no fallback that probes the image or the devices. NVX's shell init -//! reads them with the same rules, and refuses the GPT image, which it cannot assemble. It finds a -//! scratch block device beside a RAM upper among the devices that the kernel added, rather than -//! on the command line. +//! reads them with the same rules, and refuses the GPT image, the untrusted lowers, and a +//! forbidden journal, which it cannot assemble. It finds a scratch block device beside a RAM upper +//! among the devices that the kernel added, rather than on the command line. //! //! The tokens belong to the boot contract that //! [`BOOT_CONTRACT_VERSION`](crate::contract::BOOT_CONTRACT_VERSION) versions, so a change to //! them changes that version too. use std::fmt; +use std::ops::RangeInclusive; use super::cmdline::{self, Token}; use super::{ProtocolError, violation}; +use crate::activation::{Journal, OverlayMode}; use crate::uuid::Uuid; /// The MMIO base of the `distro` block device, which holds a layered root's image. @@ -47,18 +66,58 @@ pub const SCRATCH_TOKEN: &str = "nvx_scratch="; /// The token that keeps a layered root's upper in RAM. pub const RAM_UPPER_TOKEN: &str = "nvx_overlay_upper=ramfs"; +/// Prefix of the token that names the overlay's mode, `nvx_overlay_mode=`. +pub const OVERLAY_MODE_TOKEN: &str = "nvx_overlay_mode="; + +/// Prefix of the token that names whether a scratch upper may have a journal, +/// `nvx_scratch_journal=`. +pub const SCRATCH_JOURNAL_TOKEN: &str = "nvx_scratch_journal="; + +/// Prefix of the token that bounds the bytes of a GPT disk's first partition, +/// `nvx_first_partition_bound=`. +pub const FIRST_PARTITION_BOUND_TOKEN: &str = "nvx_first_partition_bound="; + +/// Prefix of the token that caps a RAM upper, in percent of the VM's memory, +/// `nvx_ram_upper_percent=`. +pub const RAM_UPPER_PERCENT_TOKEN: &str = "nvx_ram_upper_percent="; + +/// The caps of a RAM upper that the tokens carry, in percent of the VM's memory. +pub(crate) const RAM_UPPER_CAPS: RangeInclusive = 1..=90; + /// The name of the kernel parameter that names the image. const LAYER: &str = "nvx_layer"; /// The name of the kernel parameter that keeps the upper on the scratch block device. const SCRATCH: &str = "nvx_scratch"; /// The name of the kernel parameter that keeps the upper in RAM. const OVERLAY_UPPER: &str = "nvx_overlay_upper"; +/// The name of the kernel parameter that names the overlay's mode. +const OVERLAY_MODE: &str = "nvx_overlay_mode"; +/// The name of the kernel parameter that names the scratch's journal policy. +const SCRATCH_JOURNAL: &str = "nvx_scratch_journal"; +/// The name of the kernel parameter that bounds a GPT disk's first partition. +const FIRST_PARTITION_BOUND: &str = "nvx_first_partition_bound"; +/// The name of the kernel parameter that caps a RAM upper. +const RAM_UPPER_PERCENT: &str = "nvx_ram_upper_percent"; +/// The names of the kernel parameters of the boot tokens. +const PARAMETERS: [&str; 7] = [ + LAYER, + SCRATCH, + OVERLAY_UPPER, + OVERLAY_MODE, + SCRATCH_JOURNAL, + FIRST_PARTITION_BOUND, + RAM_UPPER_PERCENT, +]; /// The value of the kernel parameter that keeps the upper in RAM. const RAM_UPPER: &str = "ramfs"; /// The role of the image's block device. const DISTRO_ROLE: &str = "distro"; /// The filesystem of the scratch block device. const SCRATCH_FILESYSTEM: &str = "ext4"; +/// Every overlay mode. +const OVERLAY_MODES: [OverlayMode; 2] = [OverlayMode::TrustedLowers, OverlayMode::UntrustedLowers]; +/// Every journal policy. +const JOURNALS: [Journal; 2] = [Journal::Allowed, Journal::Forbidden]; /// The format of a layered root's image, which its token names. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -158,20 +217,32 @@ impl fmt::Display for RootUpper { } } -/// The layout of a layered root: its image, on the `distro` block device, and its upper. +/// The layout of a layered root: its image, on the `distro` block device, its upper, and the +/// settings of its assembly that it names. A setting that it leaves `None` has no token, and the +/// guest assembles the root with its own default for it. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct RootLayout { /// The read-only image. pub image: RootImage, /// The upper. pub upper: RootUpper, + /// The overlay's mode. + pub overlay: Option, + /// Whether the ext4 filesystem of a [scratch](RootUpper::Scratch) upper may have a journal. + pub journal: Option, + /// The bound of the bytes of a [GPT](ImageFormat::Gpt) disk's first partition, at least 1. + pub gpt_first_partition_bound: Option, + /// The cap of a [RAM](RootUpper::Ram) upper, in percent of the VM's memory, from 1 to 90. + pub max_memory_percent: Option, } impl RootLayout { /// Returns the layout's boot tokens: the image's, `nvx_layer=distro,0xd0003000,FORMAT`, with /// `:IDENTITY` when the image has an identity, then the upper's, `nvx_scratch=0xd0006000,ext4` - /// or `nvx_overlay_upper=ramfs`. - pub fn tokens(&self) -> [String; 2] { + /// or `nvx_overlay_upper=ramfs`, then the token of each setting that the layout names, in the + /// order of the [module](self). [`parse`](Self::parse) refuses the tokens of a setting that + /// breaks its rules. + pub fn tokens(&self) -> Vec { let RootImage { format, identity } = self.image; let layer = format!("{LAYER_TOKEN}{DISTRO_ROLE},{DISTRO_MMIO_BASE:#x},{format}"); let layer = match identity { @@ -184,7 +255,19 @@ impl RootLayout { } RootUpper::Ram => RAM_UPPER_TOKEN.to_owned(), }; - [layer, upper] + let settings = [ + self.overlay + .map(|mode| format!("{OVERLAY_MODE_TOKEN}{}", overlay_name(mode))), + self.journal + .map(|journal| format!("{SCRATCH_JOURNAL_TOKEN}{}", journal_name(journal))), + self.gpt_first_partition_bound + .map(|bound| format!("{FIRST_PARTITION_BOUND_TOKEN}{bound}")), + self.max_memory_percent + .map(|percent| format!("{RAM_UPPER_PERCENT_TOKEN}{percent}")), + ]; + let mut tokens = vec![layer, upper]; + tokens.extend(settings.into_iter().flatten()); + tokens } /// Reads the layout of a layered root from the boot tokens of a kernel command line, such as @@ -194,13 +277,14 @@ impl RootLayout { /// The command line splits at spaces, tabs, and newlines, without quoting, as the guest's init /// splits it, with [`cmdline::init_tokens`], and its tokens may come in any order. Each boot /// token must take one of the forms of the [module](self), with a value and at most once. The - /// image needs one upper, and each upper the image. A RAM upper also refuses a scratch block - /// device: the kernel adds a microVM's virtio-mmio devices from the - /// `virtio_mmio.device=SIZE@BASE:IRQ` parameters that the VMM appends, and none may sit at - /// [`SCRATCH_MMIO_BASE`]. The parser reads those parameters as the kernel does, with - /// [`cmdline::virtio_mmio_devices`], since the kernel splits its command line at more - /// whitespace than init, honors double quotes, and stops at `--`. Other tokens are ignored, as - /// are the devices that the layout uses, which the guest resolves itself. + /// image needs one upper, each upper and setting the image, and each setting the upper or the + /// format that it sets. A RAM upper also refuses a scratch block device: the kernel adds a + /// microVM's virtio-mmio devices from the `virtio_mmio.device=SIZE@BASE:IRQ` parameters that + /// the VMM appends, and none may sit at [`SCRATCH_MMIO_BASE`]. The parser reads those + /// parameters as the kernel does, with [`cmdline::virtio_mmio_devices`], since the kernel + /// splits its command line at more whitespace than init, honors double quotes, and stops at + /// `--`. Other tokens are ignored, as are the devices that the layout uses, which the guest + /// resolves itself. /// /// Fails with a [`ProtocolError`] that names the first token that breaks these rules, or the /// tokens that conflict. @@ -208,8 +292,14 @@ impl RootLayout { let mut image = None; let mut scratch = false; let mut ram = false; + let mut overlay = None; + let mut journal = None; + let mut gpt_first_partition_bound = None; + let mut max_memory_percent = None; + // The parameters that the command line names, in its order. + let mut seen = Vec::new(); for Token { name, value } in cmdline::init_tokens(command_line) { - let Some(parameter) = [LAYER, SCRATCH, OVERLAY_UPPER] + let Some(parameter) = PARAMETERS .into_iter() .find(|parameter| parameter.as_bytes() == name) else { @@ -220,16 +310,12 @@ impl RootLayout { "the {parameter} kernel parameter has no value" ))); }; - let seen = match parameter { - LAYER => image.is_some(), - SCRATCH => scratch, - _ => ram, - }; - if seen { + if seen.contains(¶meter) { return Err(violation(format!( "the {parameter} kernel parameter is repeated" ))); } + seen.push(parameter); match parameter { LAYER => image = Some(parse_image(value)?), SCRATCH => { @@ -242,7 +328,7 @@ impl RootLayout { } scratch = true; } - _ => { + OVERLAY_UPPER => { if value != RAM_UPPER.as_bytes() { return Err(violation(format!( "the {OVERLAY_UPPER} kernel parameter must be {RAM_UPPER}, not '{}'", @@ -251,6 +337,14 @@ impl RootLayout { } ram = true; } + OVERLAY_MODE => { + overlay = Some(named(parameter, value, OVERLAY_MODES, overlay_name)?) + } + SCRATCH_JOURNAL => journal = Some(named(parameter, value, JOURNALS, journal_name)?), + FIRST_PARTITION_BOUND => { + gpt_first_partition_bound = Some(number(parameter, value, 1..=u64::MAX)?); + } + _ => max_memory_percent = Some(number(parameter, value, RAM_UPPER_CAPS)?), } } let upper = match (scratch, ram) { @@ -270,25 +364,105 @@ impl RootLayout { (false, true) => Some(RootUpper::Ram), (false, false) => None, }; - match (image, upper) { - (Some(image), Some(upper)) => Ok(Some(Self { image, upper })), - (None, None) => Ok(None), - (Some(_), None) => Err(violation(format!( - "the {LAYER} kernel parameter needs an upper, {SCRATCH} or {OVERLAY_UPPER}" - ))), - (None, Some(upper)) => { - let parameter = match upper { - RootUpper::Scratch => SCRATCH, - RootUpper::Ram => OVERLAY_UPPER, - }; - Err(violation(format!( + // Without an image, the parameters that the command line names are those of an upper or + // of settings. + let Some(image) = image else { + return match seen.first() { + Some(parameter) => Err(violation(format!( "the {parameter} kernel parameter needs an image, which {LAYER} names" - ))) - } + ))), + None => Ok(None), + }; + }; + let Some(upper) = upper else { + return Err(violation(format!( + "the {LAYER} kernel parameter needs an upper, {SCRATCH} or {OVERLAY_UPPER}" + ))); + }; + let needs = if journal.is_some() && upper != RootUpper::Scratch { + Some((SCRATCH_JOURNAL, "a scratch upper, nvx_scratch")) + } else if max_memory_percent.is_some() && upper != RootUpper::Ram { + Some((RAM_UPPER_PERCENT, "a RAM upper, nvx_overlay_upper")) + } else if gpt_first_partition_bound.is_some() && image.format != ImageFormat::Gpt { + Some((FIRST_PARTITION_BOUND, "a GPT image")) + } else { + None + }; + if let Some((parameter, needs)) = needs { + return Err(violation(format!( + "the {parameter} kernel parameter needs {needs}" + ))); } + Ok(Some(Self { + image, + upper, + overlay, + journal, + gpt_first_partition_bound, + max_memory_percent, + })) + } +} + +/// Returns the name of `mode` in the overlay mode's token. +const fn overlay_name(mode: OverlayMode) -> &'static str { + match mode { + OverlayMode::TrustedLowers => "trusted-lowers", + OverlayMode::UntrustedLowers => "untrusted-lowers", } } +/// Returns the name of `journal` in the journal policy's token. +const fn journal_name(journal: Journal) -> &'static str { + match journal { + Journal::Allowed => "allowed", + Journal::Forbidden => "forbidden", + } +} + +/// Parses the value of the token of `parameter`, a setting of which `name` names each of +/// `settings`. +fn named( + parameter: &str, + value: &[u8], + settings: [T; 2], + name: fn(T) -> &'static str, +) -> Result { + let [first, second] = settings.map(name); + settings + .into_iter() + .find(|&setting| name(setting).as_bytes() == value) + .ok_or_else(|| { + violation(format!( + "the {parameter} kernel parameter must be {first} or {second}, not '{}'", + value.escape_ascii() + )) + }) +} + +/// Parses the value of the token of `parameter`, a decimal number in `range`, without a sign or +/// leading zeros. +fn number(parameter: &str, value: &[u8], range: RangeInclusive) -> Result +where + T: std::str::FromStr + PartialOrd + fmt::Display, +{ + // `str::parse` takes a leading `+` too. + std::str::from_utf8(value) + .ok() + .filter(|text| !text.starts_with(['0', '+'])) + .and_then(|text| text.parse().ok()) + .filter(|number| range.contains(number)) + .ok_or_else(|| { + violation(format!( + "the {parameter} kernel parameter must be a decimal number from {} to {}, \ + without a sign or leading zeros, not '{}'", + range.start(), + range.end(), + value.escape_ascii() + )) + }) +} + /// Parses the value of the image's token, `distro,0xd0003000,FORMAT[:IDENTITY]`. fn parse_image(value: &[u8]) -> Result { let fields: Vec<&[u8]> = value.split(|&byte| byte == b',').collect(); diff --git a/nvx-rs/src/protocol/root/tests.rs b/nvx-rs/src/protocol/root/tests.rs index 2af9335b8..241424766 100644 --- a/nvx-rs/src/protocol/root/tests.rs +++ b/nvx-rs/src/protocol/root/tests.rs @@ -14,23 +14,49 @@ const EROFS_UUID: &str = "0123abcd-4567-89ef-0123-456789abcdef"; /// The disk GUID of the tests' GPT images. const GPT_GUID: &str = "fedcba98-7654-3210-fedc-ba9876543210"; +/// The keys of a layout of the fixture. +const LAYOUT_KEYS: [&str; 7] = [ + "format", + "identity", + "upper", + "overlay", + "journal", + "gpt_first_partition_bound", + "max_memory_percent", +]; + fn uuid(text: &str) -> Uuid { Uuid::parse(text).unwrap() } +/// Returns the layout of `image` over `upper`, which names no setting. fn layout(image: RootImage, upper: RootUpper) -> RootLayout { - RootLayout { image, upper } + RootLayout { + image, + upper, + overlay: None, + journal: None, + gpt_first_partition_bound: None, + max_memory_percent: None, + } } fn parse(command_line: &str) -> Result, String> { RootLayout::parse(command_line.as_bytes()).map_err(ProtocolError::into_message) } -/// Returns the layout of a case of the fixture, or `None` for its `null`. +/// Returns the layout of a case of the fixture, or `None` for its `null`. A layout that leaves a +/// setting out names none. fn recorded_layout(value: &Value) -> Option { if value.is_null() { return None; } + for key in value.as_object().unwrap().keys() { + assert!( + LAYOUT_KEYS.contains(&key.as_str()), + "the fixture names the unknown {key}" + ); + } let format = ImageFormat::from_name(value["format"].as_str().unwrap()).unwrap(); let identity = value["identity"].as_str().map(uuid); let upper = match value["upper"].as_str().unwrap() { @@ -38,7 +64,25 @@ fn recorded_layout(value: &Value) -> Option { "ram" => RootUpper::Ram, other => panic!("the fixture names the unknown upper {other}"), }; - Some(layout(RootImage { format, identity }, upper)) + let overlay = value["overlay"].as_str().map(|mode| match mode { + "trusted-lowers" => OverlayMode::TrustedLowers, + "untrusted-lowers" => OverlayMode::UntrustedLowers, + other => panic!("the fixture names the unknown overlay mode {other}"), + }); + let journal = value["journal"].as_str().map(|journal| match journal { + "allowed" => Journal::Allowed, + "forbidden" => Journal::Forbidden, + other => panic!("the fixture names the unknown journal policy {other}"), + }); + Some(RootLayout { + overlay, + journal, + gpt_first_partition_bound: value["gpt_first_partition_bound"].as_u64(), + max_memory_percent: value["max_memory_percent"] + .as_u64() + .map(|percent| u8::try_from(percent).unwrap()), + ..layout(RootImage { format, identity }, upper) + }) } #[test] @@ -48,9 +92,26 @@ fn wire_values_keep_their_literals() { assert_eq!(LAYER_TOKEN, "nvx_layer="); assert_eq!(SCRATCH_TOKEN, "nvx_scratch="); assert_eq!(RAM_UPPER_TOKEN, "nvx_overlay_upper=ramfs"); + assert_eq!(OVERLAY_MODE_TOKEN, "nvx_overlay_mode="); + assert_eq!(SCRATCH_JOURNAL_TOKEN, "nvx_scratch_journal="); + assert_eq!(FIRST_PARTITION_BOUND_TOKEN, "nvx_first_partition_bound="); + assert_eq!(RAM_UPPER_PERCENT_TOKEN, "nvx_ram_upper_percent="); assert_eq!(format!("{LAYER}="), LAYER_TOKEN); assert_eq!(format!("{SCRATCH}="), SCRATCH_TOKEN); assert_eq!(format!("{OVERLAY_UPPER}={RAM_UPPER}"), RAM_UPPER_TOKEN); + assert_eq!(format!("{OVERLAY_MODE}="), OVERLAY_MODE_TOKEN); + assert_eq!(format!("{SCRATCH_JOURNAL}="), SCRATCH_JOURNAL_TOKEN); + assert_eq!( + format!("{FIRST_PARTITION_BOUND}="), + FIRST_PARTITION_BOUND_TOKEN + ); + assert_eq!(format!("{RAM_UPPER_PERCENT}="), RAM_UPPER_PERCENT_TOKEN); + assert_eq!( + OVERLAY_MODES.map(overlay_name), + ["trusted-lowers", "untrusted-lowers"] + ); + assert_eq!(JOURNALS.map(journal_name), ["allowed", "forbidden"]); + assert_eq!(RAM_UPPER_CAPS, 1..=90); assert_eq!(ImageFormat::Erofs.as_str(), "erofs"); assert_eq!(ImageFormat::Gpt.to_string(), "gpt"); assert_eq!(RootUpper::Scratch.as_str(), "scratch"); @@ -110,17 +171,89 @@ fn every_layout_renders_its_tokens_and_reads_them_back() { ram, ), ]; - for (expected, layer, upper) in cases { - assert_eq!(expected.tokens(), [layer.clone(), upper.to_owned()]); - // The guest reads the tokens in either order, among others. - for command_line in [ - format!("{layer} {upper}"), - format!("{upper} {layer}"), - format!("quiet nvx_sandbox=1 {layer}\t{upper}\nnvx_entrypoint=/bin/sh"), - ] { - assert_eq!(parse(&command_line), Ok(Some(expected)), "{command_line}"); + // Each setting that a layout may name, or none, with its token: the overlay's mode, a + // scratch's journal policy, a RAM upper's cap, and a GPT disk's bound. + let overlays = [ + (None, None), + ( + Some(OverlayMode::TrustedLowers), + Some("nvx_overlay_mode=trusted-lowers"), + ), + ( + Some(OverlayMode::UntrustedLowers), + Some("nvx_overlay_mode=untrusted-lowers"), + ), + ]; + let journals = [ + (None, None), + (Some(Journal::Allowed), Some("nvx_scratch_journal=allowed")), + ( + Some(Journal::Forbidden), + Some("nvx_scratch_journal=forbidden"), + ), + ]; + let caps = [ + (None, None), + (Some(1), Some("nvx_ram_upper_percent=1")), + (Some(90), Some("nvx_ram_upper_percent=90")), + ]; + let bounds = [ + (None, None), + (Some(1), Some("nvx_first_partition_bound=1")), + ( + Some(u64::MAX), + Some("nvx_first_partition_bound=18446744073709551615"), + ), + ]; + let mut layouts = 0; + for (bare, layer, upper) in cases { + let (journals, caps) = match bare.upper { + RootUpper::Scratch => (&journals[..], &[(None, None)][..]), + RootUpper::Ram => (&[(None, None)][..], &caps[..]), + }; + let bounds = match bare.image.format { + ImageFormat::Erofs => &[(None, None)][..], + ImageFormat::Gpt => &bounds[..], + }; + for &(overlay, overlay_token) in &overlays { + for &(journal, journal_token) in journals { + for &(max_memory_percent, cap_token) in caps { + for &(gpt_first_partition_bound, bound_token) in bounds { + let expected = RootLayout { + overlay, + journal, + gpt_first_partition_bound, + max_memory_percent, + ..bare + }; + let mut tokens = vec![layer.as_str(), upper]; + tokens.extend( + [overlay_token, journal_token, bound_token, cap_token] + .into_iter() + .flatten(), + ); + assert_eq!(expected.tokens(), tokens); + // The guest reads the tokens in any order, among others. + let reversed: Vec<&str> = tokens.iter().rev().copied().collect(); + for command_line in [ + tokens.join(" "), + reversed.join(" "), + format!( + "quiet nvx_sandbox=1 {}\nnvx_entrypoint=/bin/sh", + tokens.join("\t") + ), + ] { + assert_eq!(parse(&command_line), Ok(Some(expected)), "{command_line}"); + } + layouts += 1; + } + } + } } } + // Each of the 4 EROFS cases takes 3 overlay modes by 3 settings of its upper, and each of the + // 4 GPT cases 3 bounds more. + assert_eq!(layouts, 4 * 3 * 3 + 4 * 3 * 3 * 3); } #[test] @@ -166,10 +299,11 @@ fn every_reader_parses_the_recorded_command_lines_alike() { } } } - // The corpus holds every combination of format, identity, and upper. + // The corpus holds every combination of format, identity, and upper, and every combination of + // format, upper, and overlay mode with the settings that the format and the upper take. for format in ["erofs", "gpt"] { - for identity in [true, false] { - for upper in ["scratch", "ram"] { + for upper in ["scratch", "ram"] { + for identity in [true, false] { assert!( cases.iter().any(|case| { let layout = &case["layout"]; @@ -180,8 +314,34 @@ fn every_reader_parses_the_recorded_command_lines_alike() { "the fixture lacks {format} with identity {identity} over {upper}" ); } + let setting = match upper { + "scratch" => "journal", + _ => "max_memory_percent", + }; + for overlay in ["trusted-lowers", "untrusted-lowers"] { + assert!( + cases.iter().any(|case| { + let layout = &case["layout"]; + layout["format"] == format + && layout["upper"] == upper + && layout["overlay"] == overlay + && !layout[setting].is_null() + && (format == "erofs" || !layout["gpt_first_partition_bound"].is_null()) + }), + "the fixture lacks {format} over {upper} for {overlay} with its settings" + ); + } } } + // And each journal policy. + for journal in ["allowed", "forbidden"] { + assert!( + cases + .iter() + .any(|case| case["layout"]["journal"] == journal), + "the fixture lacks the journal policy {journal}" + ); + } } #[test] @@ -199,6 +359,24 @@ fn parsing_reports_bytes_that_are_not_text() { .message(), "the nvx_layer kernel parameter names the unsupported image format '\\xff'" ); + // So do the values of settings. + for (command_line, message) in [ + ( + b"nvx_overlay_mode=\xff".as_slice(), + "the nvx_overlay_mode kernel parameter must be trusted-lowers or untrusted-lowers, not \ + '\\xff'", + ), + ( + b"nvx_ram_upper_percent=9\xff".as_slice(), + "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, \ + without a sign or leading zeros, not '9\\xff'", + ), + ] { + assert_eq!( + RootLayout::parse(command_line).unwrap_err().message(), + message + ); + } // Other tokens may hold any bytes. assert_eq!( RootLayout::parse( @@ -227,12 +405,31 @@ fn contracted_upper(upper: RootUpper) -> &'static str { } } +/// Returns the token of `mode`, as the boot contract pins it. A new mode stops this match from +/// compiling until the boot contract names it. +fn contracted_overlay(mode: OverlayMode) -> &'static str { + match mode { + OverlayMode::TrustedLowers => "nvx_overlay_mode=trusted-lowers", + OverlayMode::UntrustedLowers => "nvx_overlay_mode=untrusted-lowers", + } +} + +/// Returns the token of `journal`, as the boot contract pins it. A new policy stops this match +/// from compiling until the boot contract names it. +fn contracted_journal(journal: Journal) -> &'static str { + match journal { + Journal::Allowed => "nvx_scratch_journal=allowed", + Journal::Forbidden => "nvx_scratch_journal=forbidden", + } +} + #[test] fn the_boot_contract_version_names_these_tokens() { // Runtime identities record the boot contract version, and a build stops, rather than // drives, a VM that a build of another version started. A change to these tokens, a new image // format or upper, or another way for the guest to assemble the root from them changes the // boot contract, and so its version, before the pins here change with it; see `contract`. + // The tokens of the root's settings joined version 5 while nothing deployed rendered them. assert_eq!( ( contract::BOOT_CONTRACT_VERSION, @@ -257,4 +454,32 @@ fn the_boot_contract_version_names_these_tokens() { } } } + // Each setting that a layout names adds its token after the upper's, in this order. + for overlay in OVERLAY_MODES { + for journal in JOURNALS { + let scratch = RootLayout { + overlay: Some(overlay), + journal: Some(journal), + gpt_first_partition_bound: Some(4096), + ..layout(RootImage::gpt(None), RootUpper::Scratch) + }; + assert_eq!( + scratch.tokens()[2..], + [ + contracted_overlay(overlay), + contracted_journal(journal), + "nvx_first_partition_bound=4096", + ] + ); + } + let ram = RootLayout { + overlay: Some(overlay), + max_memory_percent: Some(90), + ..layout(RootImage::erofs(None), RootUpper::Ram) + }; + assert_eq!( + ram.tokens()[2..], + [contracted_overlay(overlay), "nvx_ram_upper_percent=90"] + ); + } } diff --git a/nvx-rs/tests/fixtures/root-tokens.json b/nvx-rs/tests/fixtures/root-tokens.json index 1f3135d48..229aa07b2 100644 --- a/nvx-rs/tests/fixtures/root-tokens.json +++ b/nvx-rs/tests/fixtures/root-tokens.json @@ -1,5 +1,5 @@ { - "description": "Kernel command lines and the layout of a layered root that nvx::protocol::root parses from each, null for none, or the error with which it refuses the command line. The tests of nvx-rs replay them, and scripts/test_nvx_tools.py replays them through the shell init, guest/common/nvx-init-agent, which reads the same tokens: it accepts what the parser accepts of an EROFS image over either upper, and refuses everything else, with shell_error in its message where a case names one. It reads the tokens of a RAM upper beside a scratch block device too, and refuses that device when it finds it in /proc/iomem, where the kernel lists the virtio-mmio devices that it added.", + "description": "Kernel command lines and the layout of a layered root that nvx::protocol::root parses from each, null for none, or the error with which it refuses the command line. The tests of nvx-rs replay them, and scripts/test_nvx_tools.py replays them through the shell init, guest/common/nvx-init-agent, which reads the same tokens: it accepts what the parser accepts of an EROFS image over either upper, for trusted lowers and without a forbidden journal, and refuses everything else, with shell_error in its message where a case names one. It reads the tokens of a RAM upper beside a scratch block device too, and refuses that device when it finds it in /proc/iomem, where the kernel lists the virtio-mmio devices that it added.", "cases": [ {"name": "an EROFS image with its UUID over scratch", "command_line": "nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef nvx_scratch=0xd0006000,ext4", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "scratch"}}, {"name": "an EROFS image without an identity over scratch", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "layout": {"format": "erofs", "identity": null, "upper": "scratch"}}, @@ -79,6 +79,45 @@ {"name": "a RAM upper beside a scratch device token with text after its IRQ", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs virtio_mmio.device=0x1000@0xd0006000:22:", "layout": {"format": "erofs", "identity": null, "upper": "ram"}}, {"name": "a RAM upper beside a scratch device token in a quoted value", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs x=\"y virtio_mmio.device=0x1000@0xd0006000:22\"", "layout": {"format": "erofs", "identity": null, "upper": "ram"}}, {"name": "a RAM upper beside a scratch device token after --", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs -- virtio_mmio.device=0x1000@0xd0006000:22", "layout": {"format": "erofs", "identity": null, "upper": "ram"}}, - {"name": "a scratch upper beside its block device", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 virtio_mmio.device=0x1000@0xd0003000:19 virtio_mmio.device=0x1000@0xd0006000:22", "layout": {"format": "erofs", "identity": null, "upper": "scratch"}} + {"name": "a scratch upper beside its block device", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 virtio_mmio.device=0x1000@0xd0003000:19 virtio_mmio.device=0x1000@0xd0006000:22", "layout": {"format": "erofs", "identity": null, "upper": "scratch"}}, + {"name": "an EROFS image over scratch for trusted lowers, with a journal allowed", "command_line": "nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=trusted-lowers nvx_scratch_journal=allowed", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "scratch", "overlay": "trusted-lowers", "journal": "allowed"}}, + {"name": "an EROFS image over scratch for untrusted lowers, without a journal", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=untrusted-lowers nvx_scratch_journal=forbidden", "layout": {"format": "erofs", "identity": null, "upper": "scratch", "overlay": "untrusted-lowers", "journal": "forbidden"}, "shell_error": "untrusted lowers"}, + {"name": "an EROFS image in RAM for trusted lowers, with a cap", "command_line": "nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef nvx_overlay_upper=ramfs nvx_overlay_mode=trusted-lowers nvx_ram_upper_percent=25", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "ram", "overlay": "trusted-lowers", "max_memory_percent": 25}}, + {"name": "an EROFS image in RAM for untrusted lowers, with the largest cap, its settings first", "command_line": "nvx_ram_upper_percent=90 nvx_overlay_mode=untrusted-lowers nvx_overlay_upper=ramfs nvx_layer=distro,0xd0003000,erofs", "layout": {"format": "erofs", "identity": null, "upper": "ram", "overlay": "untrusted-lowers", "max_memory_percent": 90}, "shell_error": "untrusted lowers"}, + {"name": "a GPT image over scratch for trusted lowers, without a journal, with the smallest bound", "command_line": "nvx_layer=distro,0xd0003000,gpt:fedcba98-7654-3210-fedc-ba9876543210 nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=trusted-lowers nvx_scratch_journal=forbidden nvx_first_partition_bound=1", "layout": {"format": "gpt", "identity": "fedcba98-7654-3210-fedc-ba9876543210", "upper": "scratch", "overlay": "trusted-lowers", "journal": "forbidden", "gpt_first_partition_bound": 1}, "shell_error": "GPT image"}, + {"name": "a GPT image over scratch for untrusted lowers, with a journal allowed and the largest bound", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=untrusted-lowers nvx_scratch_journal=allowed nvx_first_partition_bound=18446744073709551615", "layout": {"format": "gpt", "identity": null, "upper": "scratch", "overlay": "untrusted-lowers", "journal": "allowed", "gpt_first_partition_bound": 18446744073709551615}, "shell_error": "GPT image"}, + {"name": "a GPT image in RAM for trusted lowers, with the smallest cap and a bound", "command_line": "nvx_layer=distro,0xd0003000,gpt:fedcba98-7654-3210-fedc-ba9876543210 nvx_overlay_upper=ramfs nvx_overlay_mode=trusted-lowers nvx_ram_upper_percent=1 nvx_first_partition_bound=4096", "layout": {"format": "gpt", "identity": "fedcba98-7654-3210-fedc-ba9876543210", "upper": "ram", "overlay": "trusted-lowers", "max_memory_percent": 1, "gpt_first_partition_bound": 4096}, "shell_error": "GPT image"}, + {"name": "a GPT image in RAM for untrusted lowers, with a cap and a bound", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_overlay_upper=ramfs nvx_overlay_mode=untrusted-lowers nvx_ram_upper_percent=50 nvx_first_partition_bound=65536", "layout": {"format": "gpt", "identity": null, "upper": "ram", "overlay": "untrusted-lowers", "max_memory_percent": 50, "gpt_first_partition_bound": 65536}, "shell_error": "GPT image"}, + {"name": "an EROFS image over scratch without a journal", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_scratch_journal=forbidden", "layout": {"format": "erofs", "identity": null, "upper": "scratch", "journal": "forbidden"}, "shell_error": "forbids the scratch a journal"}, + {"name": "the settings among the tokens of a managed sandbox", "command_line": "quiet nvx_ram_upper_percent=90 nvx_sandbox=1 nvx_overlay_mode=trusted-lowers nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef nvx_overlay_upper=ramfs nvx_entrypoint=/bin/sh", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "ram", "overlay": "trusted-lowers", "max_memory_percent": 90}}, + {"name": "parameters whose names only begin or end like the settings' tokens", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_modes=x nvx_overlay_mode_x=1 nvx-overlay-mode=untrusted-lowers nvx_scratch_journals=forbidden nvx_first_partition_bounds=0 nvx_ram_upper=0 nvx_ram_upper_percents=0 xnvx_ram_upper_percent=0", "layout": {"format": "erofs", "identity": null, "upper": "scratch"}}, + {"name": "a capped RAM upper with a scratch block device", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=40 virtio_mmio.device=0x1000@0xd0006000:22", "error": "a RAM upper takes no scratch block device, but the command line adds a virtio-mmio device at 0xd0006000"}, + {"name": "an overlay mode without a value", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_mode", "error": "the nvx_overlay_mode kernel parameter has no value"}, + {"name": "a bound without a value, before its image", "command_line": "nvx_first_partition_bound nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter has no value"}, + {"name": "two journal policies", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_scratch_journal=allowed nvx_scratch_journal=allowed", "error": "the nvx_scratch_journal kernel parameter is repeated"}, + {"name": "two caps", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=50 nvx_ram_upper_percent=60", "error": "the nvx_ram_upper_percent kernel parameter is repeated"}, + {"name": "an unknown overlay mode", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=trusted", "error": "the nvx_overlay_mode kernel parameter must be trusted-lowers or untrusted-lowers, not 'trusted'"}, + {"name": "an overlay mode in capitals", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=TRUSTED-LOWERS", "error": "the nvx_overlay_mode kernel parameter must be trusted-lowers or untrusted-lowers, not 'TRUSTED-LOWERS'"}, + {"name": "an overlay mode with an empty value", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=", "error": "the nvx_overlay_mode kernel parameter must be trusted-lowers or untrusted-lowers, not ''"}, + {"name": "an unknown journal policy", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_scratch_journal=none", "error": "the nvx_scratch_journal kernel parameter must be allowed or forbidden, not 'none'"}, + {"name": "a cap of 0, which the kernel reads as none", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=0", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '0'"}, + {"name": "a cap past 90 percent", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=91", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '91'"}, + {"name": "a cap past 8 bits", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=256", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '256'"}, + {"name": "a cap with a leading zero", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=050", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '050'"}, + {"name": "a cap with a sign", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=+50", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '+50'"}, + {"name": "a cap with a percent sign", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=50%", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '50%'"}, + {"name": "a cap with an empty value", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not ''"}, + {"name": "a bound of 0, before its image", "command_line": "nvx_first_partition_bound=0 nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '0'"}, + {"name": "a hexadecimal bound, before its image", "command_line": "nvx_first_partition_bound=0x1000 nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '0x1000'"}, + {"name": "a bound past 64 bits", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4 nvx_first_partition_bound=18446744073709551616", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'"}, + {"name": "a bound past 64 bits before an EROFS image", "command_line": "nvx_first_partition_bound=18446744073709551616 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'", "shell_error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'"}, + {"name": "a 21 digit bound before an EROFS image", "command_line": "nvx_first_partition_bound=184467440737095516150 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '184467440737095516150'", "shell_error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '184467440737095516150'"}, + {"name": "the largest bound before an EROFS image", "command_line": "nvx_first_partition_bound=18446744073709551615 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter needs a GPT image", "shell_error": "the nvx_first_partition_bound kernel parameter needs a GPT image"}, + {"name": "a 20 digit bound below the largest before an EROFS image", "command_line": "nvx_first_partition_bound=17999999999999999999 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter needs a GPT image", "shell_error": "the nvx_first_partition_bound kernel parameter needs a GPT image"}, + {"name": "a journal policy in RAM", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_scratch_journal=allowed", "error": "the nvx_scratch_journal kernel parameter needs a scratch upper, nvx_scratch"}, + {"name": "a cap over scratch", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_ram_upper_percent=50", "error": "the nvx_ram_upper_percent kernel parameter needs a RAM upper, nvx_overlay_upper"}, + {"name": "a bound of an EROFS image", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_first_partition_bound=4096", "error": "the nvx_first_partition_bound kernel parameter needs a GPT image"}, + {"name": "settings without a root", "command_line": "quiet nvx_overlay_mode=trusted-lowers nvx_scratch_journal=allowed", "error": "the nvx_overlay_mode kernel parameter needs an image, which nvx_layer names"}, + {"name": "a cap before a RAM upper without an image", "command_line": "nvx_ram_upper_percent=50 nvx_overlay_upper=ramfs", "error": "the nvx_ram_upper_percent kernel parameter needs an image, which nvx_layer names"} ] } diff --git a/scripts/test_nvx_tools.py b/scripts/test_nvx_tools.py index 402f0c285..c653e4a84 100644 --- a/scripts/test_nvx_tools.py +++ b/scripts/test_nvx_tools.py @@ -19407,7 +19407,8 @@ def setUp(self): def _read(self, command_line: str) -> subprocess.CompletedProcess[str]: """Runs the agent's reader of the root's boot tokens on `command_line`, - and prints the UUID that it expects of the distro layer and the upper.""" + and prints the UUID that it expects of the distro layer, the upper, and + the RAM upper's cap.""" functions = "".join( _shell_function(self.source, name) for name in ("is_canonical_uuid", "read_layer_token", "read_root_tokens") @@ -19424,7 +19425,7 @@ def _read(self, command_line: str) -> subprocess.CompletedProcess[str]: 'fatal() { echo "FATAL: $*" >&2; exit 125; }\n' f"{functions}" "read_root_tokens\n" - 'echo "layer_uuid=$layer_uuid upper=$upper"\n' + 'echo "layer_uuid=$layer_uuid upper=$upper ram_percent=$ram_percent"\n' ), text=True, capture_output=True, @@ -19434,7 +19435,7 @@ def _read(self, command_line: str) -> subprocess.CompletedProcess[str]: def test_agent_reads_the_boot_tokens_as_nvx_rs_reads_them(self): cases = json.loads(self.FIXTURE.read_text(encoding="utf-8"))["cases"] accepted: set[str] = set() - devices = 0 + capped = devices = 0 for case in cases: with self.subTest(case=case["name"]): result = self._read(case["command_line"]) @@ -19442,23 +19443,35 @@ def test_agent_reads_the_boot_tokens_as_nvx_rs_reads_them(self): if case.get("error") == self.SCRATCH_DEVICE_ERROR: self.assertNotIn("shell_error", case) self.assertEqual(result.returncode, 0, result.stderr) - self.assertRegex(result.stdout, r"\Alayer_uuid=\S* upper=ram\n\Z") + self.assertRegex( + result.stdout, r"\Alayer_uuid=\S* upper=ram ram_percent=\d*\n\Z" + ) devices += 1 continue - if layout is not None and layout["format"] == "erofs": + # The shell init assembles an EROFS image for trusted lowers, and + # cannot check that a scratch has no journal. + if ( + layout is not None + and layout["format"] == "erofs" + and layout.get("overlay") != "untrusted-lowers" + and layout.get("journal") != "forbidden" + ): self.assertNotIn("shell_error", case) self.assertEqual(result.returncode, 0, result.stderr) identity = layout["identity"] or "" + cap = layout.get("max_memory_percent", "") self.assertEqual( result.stdout, - f"layer_uuid={identity} upper={layout['upper']}\n", + f"layer_uuid={identity} upper={layout['upper']} ram_percent={cap}\n", ) accepted.add(layout["upper"]) + capped += cap != "" continue self.assertEqual(result.returncode, 125, result.stdout) self.assertRegex(result.stderr, r"\AFATAL: [^\n]+\n\Z") self.assertIn(case.get("shell_error", ""), result.stderr) self.assertEqual(accepted, {"scratch", "ram"}) + self.assertGreater(capped, 0) self.assertGreater(devices, 0) def test_agent_explains_the_roots_that_it_cannot_assemble(self): @@ -19473,6 +19486,83 @@ def test_agent_explains_the_roots_that_it_cannot_assemble(self): f"nvx_layer=distro,0xd0003000,gpt:{self.UUID} names a GPT image, " "which this init cannot assemble: it assembles only an EROFS image", ), + ( + "nvx_overlay_mode=untrusted-lowers nvx_layer=distro,0xd0003000,erofs " + "nvx_overlay_upper=ramfs", + "nvx_overlay_mode=untrusted-lowers names untrusted lowers, which " + "this init cannot assemble: it mounts the overlay only for trusted " + "lowers, with metacopy=on,xino=on", + ), + ( + "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 " + "nvx_scratch_journal=forbidden", + "nvx_scratch_journal=forbidden forbids the scratch a journal, which " + "this init cannot check: it reads nothing of the scratch's superblock", + ), + ): + with self.subTest(command_line=command_line): + result = self._read(command_line) + self.assertEqual(result.returncode, 125, result.stdout) + self.assertEqual(result.stderr, f"FATAL: {message}\n") + + def test_agent_refuses_the_settings_with_the_messages_of_nvx_rs(self): + scratch = "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4" + ram = "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs" + number = "a decimal number from 1 to {}, without a sign or leading zeros" + percent, bound = number.format(90), number.format(2**64 - 1) + for command_line, message in ( + ( + f"{scratch} nvx_scratch_journal", + "the nvx_scratch_journal kernel parameter has no value", + ), + ( + f"{ram} nvx_ram_upper_percent=5 nvx_ram_upper_percent=5", + "the nvx_ram_upper_percent kernel parameter is repeated", + ), + ( + f"{scratch} nvx_overlay_mode=trusted", + "the nvx_overlay_mode kernel parameter must be trusted-lowers or " + "untrusted-lowers, not 'trusted'", + ), + ( + f"{scratch} nvx_scratch_journal=", + "the nvx_scratch_journal kernel parameter must be allowed or " + "forbidden, not ''", + ), + ( + f"{ram} nvx_ram_upper_percent=91", + f"the nvx_ram_upper_percent kernel parameter must be {percent}, not '91'", + ), + ( + f"{ram} nvx_ram_upper_percent=09", + f"the nvx_ram_upper_percent kernel parameter must be {percent}, not '09'", + ), + ( + f"nvx_first_partition_bound=-1 {scratch}", + f"the nvx_first_partition_bound kernel parameter must be {bound}, " + "not '-1'", + ), + ( + f"{ram} nvx_scratch_journal=allowed", + "the nvx_scratch_journal kernel parameter needs a scratch upper, " + "nvx_scratch", + ), + ( + f"{scratch} nvx_ram_upper_percent=50", + "the nvx_ram_upper_percent kernel parameter needs a RAM upper, " + "nvx_overlay_upper", + ), + ( + f"{scratch} nvx_first_partition_bound=4096", + "the nvx_first_partition_bound kernel parameter needs a GPT image", + ), + # The parser's refusals come before those of what this init cannot + # assemble. + ( + f"{scratch} nvx_overlay_mode=untrusted-lowers nvx_scratch_journal=no", + "the nvx_scratch_journal kernel parameter must be allowed or " + "forbidden, not 'no'", + ), ): with self.subTest(command_line=command_line): result = self._read(command_line) @@ -19573,9 +19663,10 @@ def test_agent_checks_a_ram_upper_before_it_mounts_anything(self): def test_agent_mounts_the_upper_that_the_tokens_name(self): runtime = (self.root / "run").as_posix() log = self.root / "commands.log" - for upper, commands, point, name in ( + for upper, ram_percent, commands, point, name in ( ( "scratch", + "", [ f"mkdir -p {runtime}/scratch", "resolve 0xd0006000", @@ -19588,6 +19679,7 @@ def test_agent_mounts_the_upper_that_the_tokens_name(self): ), ( "ram", + "", [ f"mkdir -p {runtime}/tmpfs", f"mount -t tmpfs -o mode=0755,nosuid,nodev tmpfs {runtime}/tmpfs", @@ -19596,14 +19688,34 @@ def test_agent_mounts_the_upper_that_the_tokens_name(self): f"{runtime}/tmpfs", "the tmpfs upper", ), + ( + "ram", + "25", + [ + f"mkdir -p {runtime}/tmpfs", + "mount -t tmpfs -o mode=0755,nosuid,nodev,size=25% tmpfs " + f"{runtime}/tmpfs", + f"mkdir -p {runtime}/tmpfs/upper {runtime}/tmpfs/work", + ], + f"{runtime}/tmpfs", + "the tmpfs upper", + ), ): - with self.subTest(upper=upper): + with self.subTest(upper=upper, ram_percent=ram_percent): log.unlink(missing_ok=True) result = subprocess.run( - [self.shell, "-s", "--", runtime, log.as_posix(), upper], + [ + self.shell, + "-s", + "--", + runtime, + log.as_posix(), + upper, + ram_percent, + ], input=( "set -eu\n" - "runtime=$1\nlog=$2\nupper=$3\n" + "runtime=$1\nlog=$2\nupper=$3\nram_percent=$4\n" "scratch=$runtime/scratch\ntmpfs=$runtime/tmpfs\n" 'fatal() { echo "FATAL: $*" >&2; exit 125; }\n' 'resolve_block_device() { echo "resolve $*" >>"$log"; ' diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index d28a31e78..5a54b719e 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -91,9 +91,11 @@ what the guest did. ### Root configurations The boot tokens of `nvx::protocol::root` carry what NVX's boot contract -defines: the image's format, the identity that the guest expects of it, and -the upper. A root configuration, `root::RootConfig`, carries the rest that the -root needs, which only the adapter defines, once the host attaches. It is +defines: the image's format, the identity that the guest expects of it, the +upper, and the root's settings. The adapter's plan names none of the settings, +and the worker reads only a RAM upper's cap from them. A root configuration, +`root::RootConfig`, carries the rest that the root needs, which only the +adapter defines, once the host attaches. It is UTF-8 text: one `NAME=VALUE` line per field, each ended by a newline, in any order, and each at most once. Numbers are decimal, without a sign or leading zeros: diff --git a/tests/runtime-adapter/src/worker.rs b/tests/runtime-adapter/src/worker.rs index a0aa84bdd..dc166c88d 100644 --- a/tests/runtime-adapter/src/worker.rs +++ b/tests/runtime-adapter/src/worker.rs @@ -1236,11 +1236,11 @@ impl RootOperations for LibraryRoot { /// the EROFS image, with the identity that the boot tokens expect of it, or the GPT disk, with /// the disk GUID that they expect of it, whose first partition holds at most the /// configuration's bound, over the ext4 scratch, with the configuration's journal policy, or - /// over a tmpfs, in the configuration's overlay mode, with the configuration's runtime - /// directory, device sizes, and timeout. A bound, a policy, or a mode that the configuration - /// does not name is the guest's default: the first partition holds at most - /// [`DEFAULT_FIRST_PARTITION_BOUND`] bytes, a scratch may have a journal, and the overlay - /// trusts its lowers. + /// over a tmpfs, with the cap that the boot tokens name, if any, in the configuration's + /// overlay mode, with the configuration's runtime directory, device sizes, and timeout. A + /// bound, a policy, or a mode that the configuration does not name is the guest's default: the + /// first partition holds at most [`DEFAULT_FIRST_PARTITION_BOUND`] bytes, a scratch may have a + /// journal, and the overlay trusts its lowers. fn assemble(&mut self, config: &RootConfig, layout: &RootLayout) -> Result<(), String> { let image = match layout.image.format { ImageFormat::Erofs => Image::Erofs { @@ -1274,7 +1274,9 @@ impl RootOperations for LibraryRoot { Some(JournalPolicy::Forbidden) => Journal::Forbidden, }, }, - RootUpper::Ram => Upper::Tmpfs, + RootUpper::Ram => Upper::Tmpfs { + max_memory_percent: layout.max_memory_percent, + }, }; let overlay = match config.overlay_mode { None => root::OverlayMode::default(), diff --git a/tests/runtime-adapter/src/worker/activation/tests.rs b/tests/runtime-adapter/src/worker/activation/tests.rs index e2ac0c156..cbc439cb4 100644 --- a/tests/runtime-adapter/src/worker/activation/tests.rs +++ b/tests/runtime-adapter/src/worker/activation/tests.rs @@ -150,7 +150,9 @@ fn each_class_of_failure_ends_the_clone_with_its_own_status() { sectors: None, }, image: root::Image::Erofs { uuid: None }, - upper: root::Upper::Tmpfs, + upper: root::Upper::Tmpfs { + max_memory_percent: None, + }, overlay: root::OverlayMode::default(), timeout: Duration::ZERO, }; diff --git a/tests/runtime-adapter/tests/guest_session.rs b/tests/runtime-adapter/tests/guest_session.rs index 4980e9e67..61777bc98 100644 --- a/tests/runtime-adapter/tests/guest_session.rs +++ b/tests/runtime-adapter/tests/guest_session.rs @@ -1631,11 +1631,15 @@ impl Drop for Workload { } /// Returns a layout that the worker assembles: an EROFS image, with the identity that the guest -/// expects of it, over an ext4 scratch. +/// expects of it, over an ext4 scratch, with none of the root's settings. fn layout() -> RootLayout { RootLayout { image: RootImage::erofs(Uuid::parse("0a490905-41be-ed5f-984e-63eb7e5e20b1")), upper: RootUpper::Scratch, + overlay: None, + journal: None, + gpt_first_partition_bound: None, + max_memory_percent: None, } } @@ -1815,8 +1819,10 @@ fn the_worker_assembles_the_root_serves_it_and_tears_it_down_on_stop() { #[test] fn the_worker_assembles_an_image_over_ram_in_the_overlay_mode_of_its_configuration() { + // The boot tokens cap the RAM upper, and the worker hands the cap to the root in the layout. let ram = RootLayout { upper: RootUpper::Ram, + max_memory_percent: Some(25), ..layout() }; let config = RootConfig { @@ -1843,7 +1849,7 @@ fn the_worker_assembles_an_image_over_ram_in_the_overlay_mode_of_its_configurati fn the_worker_assembles_a_gpt_disk_with_the_first_partition_bound_of_its_configuration() { let gpt = RootLayout { image: RootImage::gpt(Uuid::parse("6c1f5e0a-2b7d-4c39-9e81-3a5d0f7b2c64")), - upper: RootUpper::Scratch, + ..layout() }; let config = RootConfig { first_partition_bound: Some(4096), diff --git a/tests/runtime-adapter/tests/layered_launch.rs b/tests/runtime-adapter/tests/layered_launch.rs index 2ac965a45..af6ed2c19 100644 --- a/tests/runtime-adapter/tests/layered_launch.rs +++ b/tests/runtime-adapter/tests/layered_launch.rs @@ -259,6 +259,10 @@ fn a_layered_launch_plans_its_blocks_and_boot_tokens_with_the_adapters_plan() { let layout = RootLayout { image: RootImage::erofs(Some(identity())), upper: RootUpper::Scratch, + overlay: None, + journal: None, + gpt_first_partition_bound: None, + max_memory_percent: None, }; assert_eq!(launch.plan.layout(), layout); // The adapter's plan holds none of the settings of NVX's sandbox profile. From 670c3cf721adb1752d558950740d4aa260eabbdb Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 14:46:37 -0800 Subject: [PATCH 11/29] test-adversarial: await the fixture's surviving child before cleanup The executor-close fixture kills its surviving Windows child, whose stderr still names a file in its temporary directory. Wait for the process object to signal before cleaning up that directory, using the existing bounded Windows wait helper and the fixture's existing cleanup deadline. Keep the workload timeout, the measured close bound, and every existing test assertion unchanged. A child that does not exit now fails explicitly instead of racing the temporary-directory cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e354d65-494d-4fa9-ae9b-4f2e86c3c0f8 --- scripts/test_adversarial.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/test_adversarial.py b/scripts/test_adversarial.py index 1b3cb3a2c..53acc998c 100644 --- a/scripts/test_adversarial.py +++ b/scripts/test_adversarial.py @@ -76,7 +76,11 @@ run_bounded_process, terminate_process_tree, ) -from nvx_tools.benchmark import InteractiveProcess, run_guest_script +from nvx_tools.benchmark import ( + InteractiveProcess, + _wait_for_windows_process, + run_guest_script, +) from nvx_tools.build_constants import ( AlpineBuildConstants, InitramfsBuildConstants, @@ -3209,6 +3213,12 @@ def test_close_is_bounded_when_a_surviving_child_holds_executor_stdout(self): check=False, capture_output=True, ) + self.assertTrue( + _wait_for_windows_process( + int(pid), max(0.0, deadline - time.monotonic()) + ), + f"surviving child {pid} did not exit after taskkill", + ) else: os.kill(int(pid), signal.SIGKILL) From 34094631777c00c5eec001d0d6f64033656281e7 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 15:44:01 -0800 Subject: [PATCH 12/29] test-adversarial: ready the contained fixture before timing its work Wait for the real fixture to publish its boot readiness and for its descendant to publish its PID before starting the existing half-second workload timeout. Keep that startup wait bounded and clean up the contained processes if readiness fails. Accept the runner's raw TimeoutError and its RuntimeError wrapper only with the expected half-second workload-timeout message. Require a live, positive descendant PID before the workload and retain the check that it has exited after the timeout. Production code, measured deadlines, and skips do not change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e354d65-494d-4fa9-ae9b-4f2e86c3c0f8 --- scripts/test_adversarial.py | 54 ++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/scripts/test_adversarial.py b/scripts/test_adversarial.py index 53acc998c..d694ba419 100644 --- a/scripts/test_adversarial.py +++ b/scripts/test_adversarial.py @@ -857,6 +857,7 @@ def spawn_unrelated( def test_contained_guest_runner_timeout_kills_descendants(self) -> None: with tempfile.TemporaryDirectory() as temporary: child_pid_path = Path(temporary) / "child.pid" + ready_path = Path(temporary) / "ready" command = [ sys.executable, "-u", @@ -864,13 +865,58 @@ def test_contained_guest_runner_timeout_kills_descendants(self) -> None: ( "import pathlib,subprocess,sys,time; " "child=subprocess.Popen([sys.executable,'-c'," - "'import time; time.sleep(60)']); " - "pathlib.Path(sys.argv[1]).write_text(str(child.pid)); " - "print('ALPINE-MICROVM-BOOT-OK',flush=True); time.sleep(60)" + "'import os,pathlib,sys,time; " + "path=pathlib.Path(sys.argv[1]); " + "path.with_suffix(\".tmp\").write_text(str(os.getpid())); " + "path.with_suffix(\".tmp\").replace(path); " + "time.sleep(60)',sys.argv[1]]); " + "print('ALPINE-MICROVM-BOOT-OK',flush=True); " + "pathlib.Path(sys.argv[2]).touch(); time.sleep(60)" ), str(child_pid_path), + str(ready_path), ] - with self.assertRaisesRegex(RuntimeError, "did not finish"): + + def start_ready( + command: Sequence[str], + environment: dict[str, str], + *, + contain_process_tree: bool, + separate_stderr: bool, + ) -> InteractiveProcess: + interaction = InteractiveProcess( + command, + environment, + contain_process_tree=contain_process_tree, + separate_stderr=separate_stderr, + ) + ready = False + try: + deadline = time.monotonic() + 5.0 + while not ready_path.exists() or not child_pid_path.exists(): + self.assertIsNone( + interaction.process.poll(), "fixture exited before readiness" + ) + self.assertLess( + time.monotonic(), deadline, "fixture did not become ready" + ) + time.sleep(0.01) + child_pid = int(child_pid_path.read_text(encoding="utf-8")) + self.assertGreater(child_pid, 0) + self.assertTrue(_process_running(child_pid)) + ready = True + return interaction + finally: + if not ready: + interaction.close() + + with ( + patch("nvx_tools.benchmark.InteractiveProcess", side_effect=start_ready), + self.assertRaisesRegex( + (TimeoutError, RuntimeError), + r"^guest workload did not finish within 0\.5s(?:\n|$)", + ), + ): run_guest_script( command, "exit\n", From 527b32e6414914bd181d69d678d0aa4c6eb3fac1 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 15:48:01 -0800 Subject: [PATCH 13/29] test-adversarial: format the contained fixture's readiness helper Apply the repository formatter's quoting and wrapping to the approved fixture correction. Its parsed Python syntax tree is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e354d65-494d-4fa9-ae9b-4f2e86c3c0f8 --- scripts/test_adversarial.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/test_adversarial.py b/scripts/test_adversarial.py index d694ba419..674fe88ea 100644 --- a/scripts/test_adversarial.py +++ b/scripts/test_adversarial.py @@ -867,8 +867,8 @@ def test_contained_guest_runner_timeout_kills_descendants(self) -> None: "child=subprocess.Popen([sys.executable,'-c'," "'import os,pathlib,sys,time; " "path=pathlib.Path(sys.argv[1]); " - "path.with_suffix(\".tmp\").write_text(str(os.getpid())); " - "path.with_suffix(\".tmp\").replace(path); " + 'path.with_suffix(".tmp").write_text(str(os.getpid())); ' + 'path.with_suffix(".tmp").replace(path); ' "time.sleep(60)',sys.argv[1]]); " "print('ALPINE-MICROVM-BOOT-OK',flush=True); " "pathlib.Path(sys.argv[2]).touch(); time.sleep(60)" @@ -895,7 +895,8 @@ def start_ready( deadline = time.monotonic() + 5.0 while not ready_path.exists() or not child_pid_path.exists(): self.assertIsNone( - interaction.process.poll(), "fixture exited before readiness" + interaction.process.poll(), + "fixture exited before readiness", ) self.assertLess( time.monotonic(), deadline, "fixture did not become ready" @@ -911,7 +912,9 @@ def start_ready( interaction.close() with ( - patch("nvx_tools.benchmark.InteractiveProcess", side_effect=start_ready), + patch( + "nvx_tools.benchmark.InteractiveProcess", side_effect=start_ready + ), self.assertRaisesRegex( (TimeoutError, RuntimeError), r"^guest workload did not finish within 0\.5s(?:\n|$)", From 5b7f85dc099e8902a5464bb3007d2ec1a7b0ecaf Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 17:03:01 -0800 Subject: [PATCH 14/29] guest: validate a bound before refusing its GPT image Read the root settings before the init refuses GPT images, so an invalid unsigned bound has the same numeric diagnostic in either token order. Keep GPT unsupported and pin the existing image-first overflow fixture's shell diagnostic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e354d65-494d-4fa9-ae9b-4f2e86c3c0f8 --- guest/common/nvx-init-agent | 15 +++++++++------ nvx-rs/tests/fixtures/root-tokens.json | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/guest/common/nvx-init-agent b/guest/common/nvx-init-agent index 15d6ea49d..164fabed5 100644 --- a/guest/common/nvx-init-agent +++ b/guest/common/nvx-init-agent @@ -178,11 +178,11 @@ is_canonical_uuid() { return 1 } -# Reads the value of the nvx_layer token for read_root_tokens: -# distro,0xd0003000,FORMAT[:IDENTITY], where this init accepts only the erofs -# format, with or without the filesystem UUID that the image must have. +# Reads the nvx_layer format and EROFS identity for read_root_tokens. +# This init assembles only EROFS; it refuses GPT after root-token validation. read_layer_token() { layer_image=${1#distro,0xd0003000,} + layer_format=${layer_image%%:*} case "$1" in distro,0xd0003000,*,*) ;; distro,0xd0003000,erofs) @@ -196,10 +196,9 @@ read_layer_token() { return 0 ;; distro,0xd0003000,gpt | distro,0xd0003000,gpt:*) - fatal "nvx_layer=$1 names a GPT image, which this init cannot assemble: it assembles only an EROFS image" + return 0 ;; distro,0xd0003000,*) - layer_format=${layer_image%%:*} # The form without a format names the UUID in its place. if [ -z "$layer_format" ] || is_canonical_uuid "$layer_format"; then fatal "the nvx_layer kernel parameter names no image format, erofs or gpt: '$1'" @@ -314,7 +313,11 @@ read_root_tokens() { fatal "the nvx_scratch_journal kernel parameter needs a scratch upper, nvx_scratch" [ -z "$ram_percent" ] || [ "$upper" = ram ] || fatal "the nvx_ram_upper_percent kernel parameter needs a RAM upper, nvx_overlay_upper" - case " $settings " in *" nvx_first_partition_bound "*) fatal "the nvx_first_partition_bound kernel parameter needs a GPT image" ;; esac + if [ "$layer_format" != gpt ]; then + case " $settings " in *" nvx_first_partition_bound "*) fatal "the nvx_first_partition_bound kernel parameter needs a GPT image" ;; esac + fi + [ "$layer_format" != gpt ] || + fatal "nvx_layer=distro,0xd0003000,$layer_image names a GPT image, which this init cannot assemble: it assembles only an EROFS image" [ "$overlay_mode" != untrusted-lowers ] || fatal "nvx_overlay_mode=untrusted-lowers names untrusted lowers, which this init cannot assemble: it mounts the overlay only for trusted lowers, with metacopy=on,xino=on" [ "$scratch_journal" != forbidden ] || diff --git a/nvx-rs/tests/fixtures/root-tokens.json b/nvx-rs/tests/fixtures/root-tokens.json index 229aa07b2..e7314963b 100644 --- a/nvx-rs/tests/fixtures/root-tokens.json +++ b/nvx-rs/tests/fixtures/root-tokens.json @@ -109,7 +109,7 @@ {"name": "a cap with an empty value", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not ''"}, {"name": "a bound of 0, before its image", "command_line": "nvx_first_partition_bound=0 nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '0'"}, {"name": "a hexadecimal bound, before its image", "command_line": "nvx_first_partition_bound=0x1000 nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '0x1000'"}, - {"name": "a bound past 64 bits", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4 nvx_first_partition_bound=18446744073709551616", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'"}, + {"name": "a bound past 64 bits", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4 nvx_first_partition_bound=18446744073709551616", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'", "shell_error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'"}, {"name": "a bound past 64 bits before an EROFS image", "command_line": "nvx_first_partition_bound=18446744073709551616 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'", "shell_error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'"}, {"name": "a 21 digit bound before an EROFS image", "command_line": "nvx_first_partition_bound=184467440737095516150 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '184467440737095516150'", "shell_error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '184467440737095516150'"}, {"name": "the largest bound before an EROFS image", "command_line": "nvx_first_partition_bound=18446744073709551615 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter needs a GPT image", "shell_error": "the nvx_first_partition_bound kernel parameter needs a GPT image"}, From 9fa5f0522979ffdcf4d860f7a5ed4007b64a8e4f Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 00:49:29 -0800 Subject: [PATCH 15/29] host: render cold-boot kernel tokens from the VM configuration Compose root settings, a byte-preserving view, the export's mapping count and the unchanged caller command line in one pure cold-boot renderer. Parse the new view tokens strictly without changing legacy consumers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c390c333-e511-4ec5-a133-8352cb86d485 --- nvx-rs/public-api/linux.txt | 6 + nvx-rs/public-api/windows.txt | 6 + nvx-rs/src/host/config/mod.rs | 49 +++++++- nvx-rs/src/host/config/tests.rs | 186 ++++++++++++++++++++++++++++++ nvx-rs/src/protocol/mod.rs | 2 + nvx-rs/src/protocol/view.rs | 129 +++++++++++++++++++++ nvx-rs/src/protocol/view/tests.rs | 124 ++++++++++++++++++++ 7 files changed, 501 insertions(+), 1 deletion(-) create mode 100644 nvx-rs/src/protocol/view.rs create mode 100644 nvx-rs/src/protocol/view/tests.rs diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index e57b8d91c..9d64ba6b6 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -1786,6 +1786,7 @@ field nvx::host::config::VmConfig::boot method nvx::host::config::VmConfig::check field nvx::host::config::VmConfig::console field nvx::host::config::VmConfig::filesystem +method nvx::host::config::VmConfig::kernel_command_line field nvx::host::config::VmConfig::memory_mib field nvx::host::config::VmConfig::network field nvx::host::config::VmConfig::processors @@ -3593,6 +3594,11 @@ const nvx::protocol::status::TIME_REPAIR const nvx::protocol::status::TIME_STATUSES const nvx::protocol::status::TIME_VIOLATION fn nvx::protocol::status::describe +mod nvx::protocol::view +const nvx::protocol::view::HOSTNAME_TOKEN +const nvx::protocol::view::MACHINE_ID_TOKEN +fn nvx::protocol::view::parse +fn nvx::protocol::view::tokens fn nvx::resolve_guest_path mod nvx::testing (hidden) mod nvx::testing::fake_openvmm (hidden) diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index ac266137b..c566cf46f 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -709,6 +709,7 @@ field nvx::host::config::VmConfig::boot method nvx::host::config::VmConfig::check field nvx::host::config::VmConfig::console field nvx::host::config::VmConfig::filesystem +method nvx::host::config::VmConfig::kernel_command_line field nvx::host::config::VmConfig::memory_mib field nvx::host::config::VmConfig::network field nvx::host::config::VmConfig::processors @@ -2515,6 +2516,11 @@ const nvx::protocol::status::TIME_REPAIR const nvx::protocol::status::TIME_STATUSES const nvx::protocol::status::TIME_VIOLATION fn nvx::protocol::status::describe +mod nvx::protocol::view +const nvx::protocol::view::HOSTNAME_TOKEN +const nvx::protocol::view::MACHINE_ID_TOKEN +fn nvx::protocol::view::parse +fn nvx::protocol::view::tokens fn nvx::resolve_guest_path mod nvx::testing (hidden) mod nvx::testing::fake_openvmm (hidden) diff --git a/nvx-rs/src/host/config/mod.rs b/nvx-rs/src/host/config/mod.rs index 9825e42e7..394e92956 100644 --- a/nvx-rs/src/host/config/mod.rs +++ b/nvx-rs/src/host/config/mod.rs @@ -11,7 +11,8 @@ use super::network::NetworkPolicy; use crate::activation::{self, Journal, MAX_HOSTNAME_BYTES, OverlayMode, View}; use crate::error::{Error, Result}; use crate::net::Ipv4Cidr; -use crate::protocol::root::{ImageFormat, RootImage}; +use crate::protocol::root::{ImageFormat, RootImage, RootLayout, RootUpper}; +use crate::protocol::view; /// The guest address of a VM's NIC at a cold boot whose configuration names none, in the form /// that [`Ipv4Cidr::parse`] reads. @@ -123,6 +124,52 @@ pub struct Boot { } impl VmConfig { + /// Renders a cold boot's kernel command line without reading the host: the root's tokens, + /// the view's tokens, the export's mapping count when present, then the caller's + /// [`Boot::command_line`] without trimming or rewriting it. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) when + /// [`check`](Self::check) refuses the configuration or when it has no [`Boot`]. A restore + /// receives its view through its activation record, not a kernel command line. + pub fn kernel_command_line(&self) -> Result { + self.check()?; + let boot = self + .boot + .as_ref() + .ok_or_else(|| Error::unsupported("a kernel command line needs a cold boot's Boot"))?; + let mut tokens = Vec::new(); + if let RootConfig::Layered(root) = &self.root { + let (upper, journal, max_memory_percent) = match &root.upper { + UpperConfig::Scratch { journal, .. } => (RootUpper::Scratch, Some(*journal), None), + UpperConfig::Ram { max_memory_percent } => { + (RootUpper::Ram, None, Some(*max_memory_percent)) + } + }; + tokens.extend( + RootLayout { + image: root.format, + upper, + overlay: Some(root.overlay), + journal, + gpt_first_partition_bound: root.gpt_first_partition_bound, + max_memory_percent, + } + .tokens(), + ); + } + tokens.extend( + view::tokens(&self.view) + .map_err(|error| Error::unsupported(error.to_string()).with_source(error))?, + ); + if let Some(export) = &self.filesystem { + tokens.push(export.kernel_token()); + } + if !boot.command_line.is_empty() { + tokens.push(boot.command_line.clone()); + } + Ok(tokens.join(" ")) + } + /// Checks the whole configuration without reading the host: it opens no path, and checks no /// support of the host. /// diff --git a/nvx-rs/src/host/config/tests.rs b/nvx-rs/src/host/config/tests.rs index 09f1f1dd5..788658de8 100644 --- a/nvx-rs/src/host/config/tests.rs +++ b/nvx-rs/src/host/config/tests.rs @@ -6,8 +6,13 @@ use std::error::Error as _; use super::*; use crate::ErrorKind; use crate::activation::MachineIdMode; +use crate::host::artifacts::Artifacts; use crate::host::filesystem::{Bind, ExportChild, Owner}; +use crate::host::launch::LaunchSpec; use crate::host::network::Access; +use crate::host::sandbox::{SandboxDistro, SandboxPlan, SandboxSettings, SandboxUpper}; +use crate::hypervisor::Hypervisor; +use crate::protocol::{cmdline, parse_host_mapping_count}; use crate::uuid::Uuid; /// Returns the path of `name` in a directory that doesn't exist. @@ -255,3 +260,184 @@ fn an_export_passes_the_check_of_the_targets_that_its_root_reserves() { assert!(source.contains(target), "{source}"); } } + +impl VmConfig { + fn legacy_command_line(&self) -> String { + let hostname = String::from_utf8(self.view.hostname.clone()).unwrap(); + match &self.root { + RootConfig::Blockless => { + let mut spec = LaunchSpec::new( + Artifacts::new("/openvmm", "/vmlinux", "/initramfs"), + Hypervisor::Kvm, + ); + spec.hostname = hostname; + spec.filesystem = self.filesystem.clone(); + spec.kernel_command_line().unwrap() + } + RootConfig::Layered(root) => { + let upper = match &root.upper { + UpperConfig::Scratch { path, .. } => SandboxUpper::scratch_at(path.clone()), + UpperConfig::Ram { .. } => SandboxUpper::RAM, + }; + SandboxPlan::new( + SandboxDistro::at(root.image.clone(), root.format), + upper, + SandboxSettings { + hostname, + ..SandboxSettings::default() + }, + ) + .unwrap() + .with_export(self.filesystem.clone()) + .unwrap() + .kernel_command_line("") + .unwrap() + } + } + } +} + +#[test] +fn cold_command_lines_keep_the_old_root_view_and_mapping_values() { + let mut configs = vec![blockless()]; + let uppers = [ + UpperConfig::Scratch { + path: missing("scratch.ext4"), + journal: Journal::Allowed, + }, + UpperConfig::Scratch { + path: missing("scratch.ext4"), + journal: Journal::Forbidden, + }, + UpperConfig::Ram { + max_memory_percent: 1, + }, + UpperConfig::Ram { + max_memory_percent: 90, + }, + ]; + for format in [ImageFormat::Erofs, ImageFormat::Gpt] { + for identity in [None, Some(Uuid::from_bytes([7; 16]))] { + for overlay in [OverlayMode::TrustedLowers, OverlayMode::UntrustedLowers] { + for upper in &uppers { + for bound in [None, Some(64 * 1024)] { + if format == ImageFormat::Erofs && bound.is_some() { + continue; + } + configs.push(layered(LayeredRoot { + format: RootImage { format, identity }, + gpt_first_partition_bound: bound, + upper: upper.clone(), + overlay, + ..erofs() + })); + } + } + } + } + } + let mut mappings = export("/work"); + mappings.binds.push(Bind { + target: "/zother".to_owned(), + ..mappings.binds[0].clone() + }); + for config in configs { + for filesystem in [None, Some(mappings.clone())] { + for machine_id in [MachineIdMode::Image, MachineIdMode::Generated] { + let mut config = VmConfig { + filesystem: filesystem.clone(), + boot: Some(Boot { + command_line: String::new(), + }), + ..config.clone() + }; + config.view.machine_id = machine_id; + let old = config.legacy_command_line(); + let actual = config.kernel_command_line().unwrap(); + let old_root = RootLayout::parse(old.as_bytes()).unwrap(); + let root = RootLayout::parse(actual.as_bytes()).unwrap(); + assert_eq!( + root.map(|root| (root.image, root.upper)), + old_root.map(|root| (root.image, root.upper)) + ); + if let Some(old_root) = old_root { + assert!(actual.starts_with(&old_root.tokens().join(" "))); + } + if let RootConfig::Layered(configured) = &config.root { + let root = root.unwrap(); + assert_eq!(root.overlay, Some(configured.overlay)); + assert_eq!( + root.gpt_first_partition_bound, + configured.gpt_first_partition_bound + ); + match configured.upper { + UpperConfig::Scratch { journal, .. } => { + assert_eq!(root.journal, Some(journal)); + assert_eq!(root.max_memory_percent, None); + } + UpperConfig::Ram { max_memory_percent } => { + assert_eq!(root.journal, None); + assert_eq!(root.max_memory_percent, Some(max_memory_percent)); + } + } + } + let decoded = view::parse(actual.as_bytes()).unwrap().unwrap(); + assert_eq!(decoded, config.view); + let old_hostname = cmdline::init_tokens(old.as_bytes()) + .find(|token| matches!(token.name, b"hostname" | b"nvx_hostname")) + .unwrap() + .value + .unwrap(); + assert_eq!(decoded.hostname, old_hostname); + let count = parse_host_mapping_count(actual.as_bytes()).unwrap(); + assert_eq!(count, parse_host_mapping_count(old.as_bytes()).unwrap()); + assert_eq!(count, if filesystem.is_some() { 2 } else { 0 }); + let names: Vec<_> = cmdline::init_tokens(actual.as_bytes()) + .skip(root.map_or(0, |root| root.tokens().len())) + .map(|token| token.name) + .collect(); + let mut expected = vec![b"nvx_hostname".as_slice(), b"nvx_machine_id"]; + if filesystem.is_some() { + expected.push(b"nvx_maps"); + } + assert_eq!(names, expected); + } + } + } +} + +#[test] +fn caller_tokens_follow_rendered_tokens_without_any_rewriting() { + let caller = " \tquiet nvx_entrypoint=/bin/work nvx_arg=\"x y\" nvx_share=0,/work\r\n "; + let mut config = VmConfig { + boot: Some(Boot { + command_line: String::new(), + }), + ..blockless() + }; + let rendered = config.kernel_command_line().unwrap(); + assert!(!rendered.ends_with(' ')); + config.boot.as_mut().unwrap().command_line = caller.to_owned(); + assert_eq!( + config.kernel_command_line().unwrap(), + format!("{rendered} {caller}") + ); +} + +#[test] +fn only_a_checked_cold_boot_has_a_kernel_command_line() { + let config = blockless(); + let error = config.kernel_command_line().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!(error.to_string().contains("Boot"), "{error}"); + let config = VmConfig { + processors: 0, + boot: Some(Boot { + command_line: String::new(), + }), + ..config + }; + let error = config.kernel_command_line().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!(error.to_string().contains("processor"), "{error}"); +} diff --git a/nvx-rs/src/protocol/mod.rs b/nvx-rs/src/protocol/mod.rs index e38062744..5d36700a3 100644 --- a/nvx-rs/src/protocol/mod.rs +++ b/nvx-rs/src/protocol/mod.rs @@ -21,6 +21,7 @@ //! server, which OpenVMM's `virtnet_dns=` token names, with the resolver file that names it. //! - [`root`]: the boot tokens of a layered root, which name its image's format and identity and //! its upper, with the host's renderer and the guest's parser. +//! - [`view`]: a cold boot's hostname bytes and machine-ID mode, with their renderer and parser. //! - [`export`]: the boot tokens of the host export, the aggregate virtio-fs device from which a //! guest maps host paths, and the guest's strict parser of them. //! - [`share`]: the guest targets that a sandbox's live shares may not take, which the host and @@ -52,6 +53,7 @@ pub mod share; pub mod status; #[cfg(test)] mod tests; +pub mod view; pub use app::{ APP_CANCEL, APP_ERROR, APP_EXEC, APP_EXIT, APP_FEATURES, APP_HEADER_LEN, APP_MAGIC, APP_MAPS, diff --git a/nvx-rs/src/protocol/view.rs b/nvx-rs/src/protocol/view.rs new file mode 100644 index 000000000..79b618e21 --- /dev/null +++ b/nvx-rs/src/protocol/view.rs @@ -0,0 +1,129 @@ +//! A cold boot's view: `nvx_hostname=HEX`, the hostname's bytes as lowercase hexadecimal, two +//! digits per byte, and `nvx_machine_id=image` or `nvx_machine_id=generated`. +//! +//! The hostname holds 1 to [`MAX_HOSTNAME_BYTES`] bytes without a NUL, as [`View`] specifies, +//! without a UTF-8 or DNS restriction. It is always encoded, even for an ASCII hostname: +//! `box` is `nvx_hostname=626f78`, not the legacy sandbox's raw `nvx_hostname=box`. +//! Consumers of this codec use the decoded bytes, not the legacy raw-hostname reader. +//! Restores receive their view through an activation record instead. + +use super::cmdline::{self, Token}; +use super::{ProtocolError, violation}; +use crate::activation::{self, MAX_HOSTNAME_BYTES, MachineIdMode, View}; + +/// Prefix of the token that encodes the hostname's bytes as lowercase hexadecimal. +pub const HOSTNAME_TOKEN: &str = "nvx_hostname="; +/// Prefix of the token that names the machine-ID mode, `image` or `generated`. +pub const MACHINE_ID_TOKEN: &str = "nvx_machine_id="; + +/// Renders the hostname token, then the machine-ID token. +/// +/// Fails with a [`ProtocolError`] if the hostname is not 1 to [`MAX_HOSTNAME_BYTES`] non-NUL +/// bytes. Its hexadecimal value needs no quoting or escaping on the kernel command line. +pub fn tokens(view: &View) -> Result<[String; 2], ProtocolError> { + if !activation::is_hostname(&view.hostname) { + return Err(violation(format!( + "the view's hostname must be 1 to {MAX_HOSTNAME_BYTES} bytes without a NUL" + ))); + } + let hostname: String = view + .hostname + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let machine_id = match view.machine_id { + MachineIdMode::Image => "image", + MachineIdMode::Generated => "generated", + }; + Ok([ + format!("{HOSTNAME_TOKEN}{hostname}"), + format!("{MACHINE_ID_TOKEN}{machine_id}"), + ]) +} + +/// Reads a complete view, or `None` when neither token is present. +/// +/// Reads exact token names with [`cmdline::init_tokens`]: spaces, tabs and newlines separate +/// tokens, quotes are literal, and `--` does not stop the reader. Both tokens must occur +/// exactly once, in either order. Missing values, partial pairs, duplicates and values outside +/// the forms of the [module](self) fail with a [`ProtocolError`]; unrelated tokens are ignored. +pub fn parse(command_line: &[u8]) -> Result, ProtocolError> { + let mut hostname = None; + let mut machine_id = None; + for Token { name, value } in cmdline::init_tokens(command_line) { + let parameter = match name { + b"nvx_hostname" => "nvx_hostname", + b"nvx_machine_id" => "nvx_machine_id", + _ => continue, + }; + let value = value + .ok_or_else(|| violation(format!("the {parameter} kernel parameter has no value")))?; + let repeated = if parameter == "nvx_hostname" { + hostname.is_some() + } else { + machine_id.is_some() + }; + if repeated { + return Err(violation(format!( + "the {parameter} kernel parameter is repeated" + ))); + } + if parameter == "nvx_hostname" { + hostname = Some(parse_hostname(value)?); + } else { + machine_id = Some(match value { + b"image" => MachineIdMode::Image, + b"generated" => MachineIdMode::Generated, + _ => { + return Err(violation( + "the nvx_machine_id kernel parameter must be image or generated", + )); + } + }); + } + } + match (hostname, machine_id) { + (Some(hostname), Some(machine_id)) => Ok(Some(View { + hostname, + machine_id, + })), + (None, None) => Ok(None), + _ => Err(violation( + "a view needs both nvx_hostname and nvx_machine_id kernel parameters", + )), + } +} + +/// Decodes a bounded, canonical hostname value, refusing a decoded NUL. +fn parse_hostname(value: &[u8]) -> Result, ProtocolError> { + if value.is_empty() + || value.len() > 2 * MAX_HOSTNAME_BYTES + || !value.len().is_multiple_of(2) + || !value + .iter() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return Err(violation(format!( + "the nvx_hostname kernel parameter must encode 1 to {MAX_HOSTNAME_BYTES} bytes \ + as lowercase hexadecimal" + ))); + } + let digit = |byte: u8| { + if byte.is_ascii_digit() { + byte - b'0' + } else { + byte - b'a' + 10 + } + }; + let hostname: Vec = value + .chunks_exact(2) + .map(|pair| digit(pair[0]) * 16 + digit(pair[1])) + .collect(); + if !activation::is_hostname(&hostname) { + return Err(violation("the nvx_hostname kernel parameter encodes a NUL")); + } + Ok(hostname) +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/protocol/view/tests.rs b/nvx-rs/src/protocol/view/tests.rs new file mode 100644 index 000000000..3311ca664 --- /dev/null +++ b/nvx-rs/src/protocol/view/tests.rs @@ -0,0 +1,124 @@ +use super::*; + +#[test] +fn view_tokens_preserve_hostname_bytes_and_both_machine_id_modes() { + for hostname in [ + b"x".to_vec(), + b"box".to_vec(), + b" \t\n\"=\\\x80\xff".to_vec(), + vec![0xff; MAX_HOSTNAME_BYTES], + ] { + for machine_id in [MachineIdMode::Image, MachineIdMode::Generated] { + let view = View { + hostname: hostname.clone(), + machine_id, + }; + let rendered = tokens(&view).unwrap(); + assert_eq!(parse(rendered.join(" ").as_bytes()).unwrap(), Some(view)); + } + } + assert_eq!( + tokens(&View { + hostname: b"box".to_vec(), + machine_id: MachineIdMode::Generated, + }) + .unwrap(), + ["nvx_hostname=626f78", "nvx_machine_id=generated"] + ); +} + +#[test] +fn a_view_is_absent_only_when_neither_exact_init_token_is_present() { + for unrelated in [ + "", + "quiet nvx-hostname=box nvx-machine-id=unknown", + "\"nvx_hostname=626f78\" other=nvx_machine_id=image", + ] { + assert_eq!(parse(unrelated.as_bytes()).unwrap(), None); + } + assert_eq!( + parse(b"quiet\t-- nvx_machine_id=image\nnvx_hostname=626f78").unwrap(), + Some(View { + hostname: b"box".to_vec(), + machine_id: MachineIdMode::Image, + }) + ); +} + +#[test] +fn hostname_values_must_be_bounded_canonical_hexadecimal_without_a_nul() { + let mut malformed: Vec> = [ + b"".as_slice(), + b"0", + b"gg", + b"6A", + b"0x62", + b"box", + b"62006f", + b"62=6f", + b"62\r", + b"62\0", + b"\"62\"", + b"\xff\xff", + ] + .into_iter() + .map(<[u8]>::to_vec) + .collect(); + malformed.push(b"62".repeat(MAX_HOSTNAME_BYTES + 1)); + for value in malformed { + let line = [ + b"nvx_hostname=".as_slice(), + &value, + b" nvx_machine_id=image", + ] + .concat(); + let error = parse(&line).unwrap_err(); + assert!(error.to_string().contains("nvx_hostname"), "{error}"); + } + for hostname in [ + Vec::new(), + vec![b'a'; MAX_HOSTNAME_BYTES + 1], + b"a\0b".to_vec(), + ] { + assert!( + tokens(&View { + hostname, + machine_id: MachineIdMode::Image, + }) + .is_err() + ); + } +} + +#[test] +fn machine_id_values_are_exact_and_unquoted() { + for value in [ + b"".as_slice(), + b"Image", + b"generate", + b"image=generated", + b"image\r", + b"\"image\"", + b"\xff", + ] { + let line = [b"nvx_hostname=626f78 nvx_machine_id=".as_slice(), value].concat(); + let error = parse(&line).unwrap_err(); + assert!(error.to_string().contains("nvx_machine_id"), "{error}"); + } +} + +#[test] +fn partial_missing_and_duplicate_owned_tokens_are_refused() { + for line in [ + "nvx_hostname=626f78", + "nvx_machine_id=image", + "nvx_hostname nvx_machine_id=image", + "nvx_hostname=626f78 nvx_machine_id", + "nvx_hostname=626f78 nvx_hostname=626f78 nvx_machine_id=image", + "nvx_hostname=626f78 nvx_hostname=61 nvx_machine_id=image", + "nvx_hostname=626f78 nvx_machine_id=image nvx_machine_id=image", + "nvx_hostname=626f78 nvx_machine_id=image nvx_machine_id=generated", + ] { + assert!(parse(line.as_bytes()).is_err(), "{line}"); + } +} From 68373ce65717d397adec224b404a8dcf7e8bfdff Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 01:13:24 -0800 Subject: [PATCH 16/29] doc: describe cold-boot view tokens and configuration rendering Document the byte-preserving view codec and pure kernel command-line renderer alongside the other module contracts. Clarify that caller text is appended unchanged rather than checked for token collisions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c390c333-e511-4ec5-a133-8352cb86d485 --- nvx-rs/README.md | 30 ++++++++++++++++++++++++++---- nvx-rs/src/host/config/mod.rs | 5 +++-- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 106f9de8b..87bdb30df 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -114,6 +114,7 @@ changes only when the module's purpose does. | [`nvx::protocol::root`](#nvxprotocolroot) | The boot tokens that tell the guest which image its layered root assembles, where the root keeps its upper, and how the guest assembles them | | [`nvx::protocol::share`](#nvxprotocolshare) | The guest targets that a sandbox's live shares may not take, which the host and the guest both refuse | | [`nvx::protocol::status`](#nvxprotocolstatus) | The exit statuses with which a clone's guest ends its VM once it fails fast, and what each means | +| [`nvx::protocol::view`](#nvxprotocolview) | A cold boot's hostname bytes and machine-ID mode, with their renderer and strict boot-token parser | | [`nvx::activation`](#nvxactivation) | The activation record of a layered root, which a clone reads in place of its captured configuration, and what Ready reports | | [`nvx::contract`](#nvxcontract) | The versions of the contracts that the crate speaks with OpenVMM and the guest, and the compatibility of releases with them | | [`nvx::net`](#nvxnet) | Network primitives that do not depend on the host: CIDR networks, hostnames, guest addresses, and gateways | @@ -123,7 +124,7 @@ changes only when the module's purpose does. | [`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::config`](#nvxhostconfig) | `VmConfig`: a VM's processors, memory, root, view, network, export, boot console, and cold-boot settings as one value, checked without reading the host | +| [`nvx::host::config`](#nvxhostconfig) | `VmConfig`: one VM configuration, checked and lowered into cold-boot kernel tokens without reading the host | | [`nvx::host::artifacts`](#nvxhostartifacts) | Locating the OpenVMM executable and the guest artifacts that a VM runs, and their runtime identity | | [`nvx::host::launch`](#nvxhostlaunch) | `LaunchSpec` and `VmmLaunch`: the OpenVMM command line of a managed microVM, its checks, and the inputs that it shares with an adapter's launch | | [`nvx::host::network`](#nvxhostnetwork) | The one lowering of a network posture into OpenVMM options, for the CLI and external orchestrators | @@ -281,6 +282,21 @@ class of a clone's activation's failures, `ACTIVATION_DEADLINE` to `describe` tells what a status means. +### `nvx::protocol::view` + +`tokens` renders a cold boot's `activation::View`: `nvx_hostname=HEX`, always +lowercase hexadecimal with two digits per hostname byte, then +`nvx_machine_id=image` or `nvx_machine_id=generated`. The hostname keeps the +view's 1 to 64 non-NUL bytes, including whitespace and non-UTF-8 bytes, without +quoting. For example, `box` becomes `nvx_hostname=626f78`, not the legacy +sandbox's raw `nvx_hostname=box`; consumers use the decoded bytes. + +`parse` reads the exact names through `cmdline::init_tokens`, in either order, +and returns a `View`, or `None` when neither token is present. A partial pair, +duplicate, missing value, malformed hexadecimal, overlong hostname, decoded +NUL, or unknown machine-ID mode fails with `ProtocolError`. Restores receive +their view through the activation record instead. + ### `nvx::activation` The activation record of a layered root: one bounded, versioned, non-secret @@ -486,15 +502,21 @@ settled on, with its address. A `Boot` holds the caller's own kernel command-line tokens, to which NVX adds those of what it lowers: a VM that boots needs one, and a VM that a snapshot restores takes none. +`VmConfig::kernel_command_line` checks the configuration and requires `Boot`, +then renders the root's tokens and settings, the view's tokens, and `nvx_maps` +when an export exists, followed by `Boot.command_line` unchanged. It neither +reads the host nor checks or rewrites caller text. A restore has no kernel +command line from this method and fails with kind `Unsupported`. + `VmConfig::check` checks the whole value without reading the host: it opens no path and checks no support of the host. It fails with an `nvx::Error` of kind `Unsupported` that names the first refusal: no processor or no memory; a layered root without an image or a scratch path, with a bound of an EROFS image or of 0, or with a RAM upper's cap outside 1 to 90 percent; a hostname that an activation record refuses; or an export that `ExportPlan::check` refuses for the guest -targets that the root reserves. The network's policy and the boot's command line -are checked where they are lowered, with the guest's address and NVX's own -tokens. +targets that the root reserves. The network's policy is checked where it is +lowered, with the guest's address. This check does not inspect the boot's +command-line text. ### `nvx::host::artifacts` diff --git a/nvx-rs/src/host/config/mod.rs b/nvx-rs/src/host/config/mod.rs index 394e92956..1af0f98ef 100644 --- a/nvx-rs/src/host/config/mod.rs +++ b/nvx-rs/src/host/config/mod.rs @@ -180,8 +180,9 @@ impl VmConfig { /// empty, longer than [`MAX_HOSTNAME_BYTES`], or holds a NUL; and an export that /// [`ExportPlan::check`] refuses for the root, whose error is the source. /// - /// The NIC's address is checked when it is built. The network's policy and the boot's command - /// line are checked where they are lowered, with the guest's address and NVX's own tokens. + /// The NIC's address is checked when it is built, and the network's policy where it is + /// lowered, with the guest's address. This check does not inspect [`Boot::command_line`], + /// which [`kernel_command_line`](Self::kernel_command_line) appends unchanged. pub fn check(&self) -> Result<()> { if self.processors == 0 { return Err(Error::unsupported("a VM needs at least one processor")); From 4068b5b44e499bb9ca9b1b3191864e9fe763f4dc Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 07:44:40 -0800 Subject: [PATCH 17/29] host: refuse caller overrides of rendered boot tokens Check caller boot tokens with the existing kernel and init readers while leaving other guest tokens caller-owned. Expose the exact export mapping table and map codec, retaining the protocol-root re-exports, and move the kernel command-line budget into configuration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0aa8163c-8d27-4d54-be61-d9b23c1bc15c --- nvx-rs/public-api/linux.txt | 26 ++++- nvx-rs/public-api/windows.txt | 26 ++++- nvx-rs/src/host/config/mod.rs | 58 ++++++++++- nvx-rs/src/host/config/tests.rs | 169 ++++++++++++++++++++++++++++++++ nvx-rs/src/host/launch.rs | 3 +- nvx-rs/src/protocol/mod.rs | 2 +- 6 files changed, 275 insertions(+), 9 deletions(-) diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 9d64ba6b6..2ddd37d4a 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -1755,6 +1755,7 @@ fn nvx::host::cli::main (hidden) mod nvx::host::config struct nvx::host::config::Boot field nvx::host::config::Boot::command_line +const nvx::host::config::COMMAND_LINE_BUDGET enum nvx::host::config::ConsoleAttachment variant nvx::host::config::ConsoleAttachment::Disconnected variant nvx::host::config::ConsoleAttachment::Listener @@ -1787,6 +1788,7 @@ method nvx::host::config::VmConfig::check field nvx::host::config::VmConfig::console field nvx::host::config::VmConfig::filesystem 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 field nvx::host::config::VmConfig::processors @@ -1918,7 +1920,6 @@ fn nvx::host::instance::remove_interrupted_writes fn nvx::host::instance::write_json fn nvx::host::instance::write_private mod nvx::host::launch -const nvx::host::launch::COMMAND_LINE_BUDGET const nvx::host::launch::DEFAULT_GUEST_NETWORK const nvx::host::launch::DEFAULT_HOSTNAME const nvx::host::launch::DEFAULT_MEMORY_MIB @@ -3386,6 +3387,29 @@ struct nvx::protocol::export::HostExport field nvx::protocol::export::HostExport::mode method nvx::protocol::export::HostExport::parse field nvx::protocol::export::HostExport::tag +mod nvx::protocol::maps +struct nvx::protocol::maps::GuestHostMap +field nvx::protocol::maps::GuestHostMap::read_only +field nvx::protocol::maps::GuestHostMap::source +field nvx::protocol::maps::GuestHostMap::target +struct nvx::protocol::maps::GuestMapsPayload +field nvx::protocol::maps::GuestMapsPayload::first +field nvx::protocol::maps::GuestMapsPayload::maps +struct nvx::protocol::maps::HostMap +field nvx::protocol::maps::HostMap::read_only +field nvx::protocol::maps::HostMap::source +field nvx::protocol::maps::HostMap::target +const nvx::protocol::maps::MAPS_HEADER_LEN +const nvx::protocol::maps::MAP_ENTRY_HEADER_LEN +const nvx::protocol::maps::MAP_READ_ONLY +const nvx::protocol::maps::MAX_HOST_MAPPINGS +const nvx::protocol::maps::MAX_MAP_PATH_BYTES +struct nvx::protocol::maps::MapsPayload +field nvx::protocol::maps::MapsPayload::first +field nvx::protocol::maps::MapsPayload::maps +fn nvx::protocol::maps::decode_guest_maps_payload +fn nvx::protocol::maps::decode_maps_payload +fn nvx::protocol::maps::encode_maps_payloads mod nvx::protocol::nvxs enum nvx::protocol::nvxs::ErrorCode variant nvx::protocol::nvxs::ErrorCode::Authentication diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index c566cf46f..cd144a51d 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -678,6 +678,7 @@ fn nvx::host::cli::main (hidden) mod nvx::host::config struct nvx::host::config::Boot field nvx::host::config::Boot::command_line +const nvx::host::config::COMMAND_LINE_BUDGET enum nvx::host::config::ConsoleAttachment variant nvx::host::config::ConsoleAttachment::Disconnected variant nvx::host::config::ConsoleAttachment::Listener @@ -710,6 +711,7 @@ method nvx::host::config::VmConfig::check field nvx::host::config::VmConfig::console field nvx::host::config::VmConfig::filesystem 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 field nvx::host::config::VmConfig::processors @@ -841,7 +843,6 @@ fn nvx::host::instance::remove_interrupted_writes fn nvx::host::instance::write_json fn nvx::host::instance::write_private mod nvx::host::launch -const nvx::host::launch::COMMAND_LINE_BUDGET const nvx::host::launch::DEFAULT_GUEST_NETWORK const nvx::host::launch::DEFAULT_HOSTNAME const nvx::host::launch::DEFAULT_MEMORY_MIB @@ -2308,6 +2309,29 @@ struct nvx::protocol::export::HostExport field nvx::protocol::export::HostExport::mode method nvx::protocol::export::HostExport::parse field nvx::protocol::export::HostExport::tag +mod nvx::protocol::maps +struct nvx::protocol::maps::GuestHostMap +field nvx::protocol::maps::GuestHostMap::read_only +field nvx::protocol::maps::GuestHostMap::source +field nvx::protocol::maps::GuestHostMap::target +struct nvx::protocol::maps::GuestMapsPayload +field nvx::protocol::maps::GuestMapsPayload::first +field nvx::protocol::maps::GuestMapsPayload::maps +struct nvx::protocol::maps::HostMap +field nvx::protocol::maps::HostMap::read_only +field nvx::protocol::maps::HostMap::source +field nvx::protocol::maps::HostMap::target +const nvx::protocol::maps::MAPS_HEADER_LEN +const nvx::protocol::maps::MAP_ENTRY_HEADER_LEN +const nvx::protocol::maps::MAP_READ_ONLY +const nvx::protocol::maps::MAX_HOST_MAPPINGS +const nvx::protocol::maps::MAX_MAP_PATH_BYTES +struct nvx::protocol::maps::MapsPayload +field nvx::protocol::maps::MapsPayload::first +field nvx::protocol::maps::MapsPayload::maps +fn nvx::protocol::maps::decode_guest_maps_payload +fn nvx::protocol::maps::decode_maps_payload +fn nvx::protocol::maps::encode_maps_payloads mod nvx::protocol::nvxs enum nvx::protocol::nvxs::ErrorCode variant nvx::protocol::nvxs::ErrorCode::Authentication diff --git a/nvx-rs/src/host/config/mod.rs b/nvx-rs/src/host/config/mod.rs index 1af0f98ef..14cb3e60a 100644 --- a/nvx-rs/src/host/config/mod.rs +++ b/nvx-rs/src/host/config/mod.rs @@ -11,9 +11,14 @@ use super::network::NetworkPolicy; use crate::activation::{self, Journal, MAX_HOSTNAME_BYTES, OverlayMode, View}; use crate::error::{Error, Result}; use crate::net::Ipv4Cidr; +use crate::protocol::cmdline::{self, Parameter}; +use crate::protocol::maps::HostMap; use crate::protocol::root::{ImageFormat, RootImage, RootLayout, RootUpper}; use crate::protocol::view; +/// Bytes of the x86 kernel command line available to caller tokens, including the NUL. +pub const COMMAND_LINE_BUDGET: usize = 1024; + /// The guest address of a VM's NIC at a cold boot whose configuration names none, in the form /// that [`Ipv4Cidr::parse`] reads. pub const DEFAULT_GUEST_NETWORK: &str = "10.0.0.2/24"; @@ -120,10 +125,23 @@ pub struct NicConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct Boot { /// The caller's own kernel command-line tokens, to which NVX adds those of what it lowers. + /// The root's tokens, `nvx_hostname`, `nvx_machine_id`, `nvx_maps` and `virtfs_*` are reserved; + /// every other token, including `nvx_entrypoint`, `nvx_arg` and `nvx_share`, is caller-owned. pub command_line: String, } impl VmConfig { + /// Returns the configured export's mapping table, in exactly the order and form of + /// [`ExportPlan::guest_table`], or an empty table without an export. + /// + /// This is a pure projection: it reads no host path and does not recheck the plan. A cold + /// boot's rendered `nvx_maps` token counts these entries. + pub fn mapping_table(&self) -> Vec { + self.filesystem + .as_ref() + .map_or_else(Vec::new, ExportPlan::guest_table) + } + /// Renders a cold boot's kernel command line without reading the host: the root's tokens, /// the view's tokens, the export's mapping count when present, then the caller's /// [`Boot::command_line`] without trimming or rewriting it. @@ -177,12 +195,14 @@ impl VmConfig { /// refusal in the order of the fields: no processor; no memory; a layered root without an image /// path, with a bound of an EROFS image's first partition or a bound of 0, with a scratch upper /// without a path, or with a RAM upper whose cap is not 1 to 90 percent; a hostname that is - /// empty, longer than [`MAX_HOSTNAME_BYTES`], or holds a NUL; and an export that - /// [`ExportPlan::check`] refuses for the root, whose error is the source. + /// empty, longer than [`MAX_HOSTNAME_BYTES`], or holds a NUL; an export that + /// [`ExportPlan::check`] refuses for the root, whose error is the source; and caller boot + /// tokens that collide with the root's, the view's, `nvx_maps`, or any `virtfs_*` name. /// /// The NIC's address is checked when it is built, and the network's policy where it is - /// lowered, with the guest's address. This check does not inspect [`Boot::command_line`], - /// which [`kernel_command_line`](Self::kernel_command_line) appends unchanged. + /// lowered, with the guest's address. Boot tokens are read as both Linux and the guest's init + /// read them, with dashes and underscores matching as Linux matches parameter names. + /// [`kernel_command_line`](Self::kernel_command_line) appends accepted caller text unchanged. pub fn check(&self) -> Result<()> { if self.processors == 0 { return Err(Error::unsupported("a VM needs at least one processor")); @@ -207,6 +227,36 @@ impl VmConfig { Error::unsupported("the export plan is invalid").with_source(error) })?; } + if let Some(boot) = &self.boot { + let guest = cmdline::init_tokens(boot.command_line.as_bytes()).map(|token| Parameter { + name: token.name, + value: token.value, + }); + for parameter in cmdline::parameters(boot.command_line.as_bytes()).chain(guest) { + if [ + "nvx_layer", + "nvx_scratch", + "nvx_overlay_upper", + "nvx_overlay_mode", + "nvx_scratch_journal", + "nvx_first_partition_bound", + "nvx_ram_upper_percent", + "nvx_hostname", + "nvx_machine_id", + "nvx_maps", + ] + .iter() + .any(|name| parameter.is_named(name)) + || parameter.name.starts_with(b"virtfs_") + || parameter.name.starts_with(b"virtfs-") + { + return Err(Error::unsupported(format!( + "Boot.command_line uses the reserved token {:?}", + String::from_utf8_lossy(parameter.name) + ))); + } + } + } Ok(()) } } diff --git a/nvx-rs/src/host/config/tests.rs b/nvx-rs/src/host/config/tests.rs index 788658de8..9a9a33614 100644 --- a/nvx-rs/src/host/config/tests.rs +++ b/nvx-rs/src/host/config/tests.rs @@ -441,3 +441,172 @@ fn only_a_checked_cold_boot_has_a_kernel_command_line() { assert_eq!(error.kind(), ErrorKind::Unsupported); assert!(error.to_string().contains("processor"), "{error}"); } + +#[test] +fn caller_tokens_cannot_replace_rendered_tokens_under_kernel_name_matching() { + let reserved = [ + "nvx_layer", + "nvx_scratch", + "nvx_overlay_upper", + "nvx_overlay_mode", + "nvx_scratch_journal", + "nvx_first_partition_bound", + "nvx_ram_upper_percent", + "nvx_hostname", + "nvx_machine_id", + "nvx_maps", + "virtfs_dir", + "virtfs_tag", + "virtfs_mode", + "virtfs_aggregate", + "virtfs_future_option", + "virtfs_", + ]; + for name in reserved { + let underscores = name.bytes().filter(|&byte| byte == b'_').count(); + for mask in 0..(1 << underscores) { + let mut index = 0; + let spelling: String = name + .chars() + .map(|character| { + if character != '_' { + return character; + } + let dash = mask & (1 << index) != 0; + index += 1; + if dash { '-' } else { '_' } + }) + .collect(); + for command_line in [ + spelling.clone(), + format!("{spelling}=value"), + format!("quiet \"{spelling}=value\""), + format!("quiet\u{a0}{spelling}=value"), + format!("-- {spelling}=value"), + format!("other=\"value {spelling}=value\""), + ] { + for config in [blockless(), layered(erofs())] { + let config = VmConfig { + boot: Some(Boot { + command_line: command_line.clone(), + }), + ..config + }; + for error in [ + config.check().unwrap_err(), + config.kernel_command_line().unwrap_err(), + ] { + assert_eq!(error.kind(), ErrorKind::Unsupported, "{command_line:?}"); + assert!( + error.to_string().contains("Boot.command_line"), + "{command_line:?}: {error}" + ); + } + } + } + } + } +} + +#[test] +fn caller_owned_guest_tokens_and_noncolliding_names_stay_unchanged() { + for command_line in [ + "nvx_entrypoint=/bin/work nvx_arg=\"x y\" nvx_share=0,/work", + "nvx-entrypoint=/bin/work nvx-arg=x nvx-share=0,/work", + "nvx_sandbox=1 nvx_lifecycle=one-shot nvx_workload_account=1", + "quiet tsc=reliable hostname=caller root=/dev/custom", + "nvx_maps_extra=1 NVX_MAPS=1 virtfs=1 virtfsx_name=1 other_virtfs_dir=1", + "other=nvx_maps=1 -- nvx_arg=nvx_hostname", + ] { + let mut config = VmConfig { + boot: Some(Boot { + command_line: String::new(), + }), + ..blockless() + }; + let rendered = config.kernel_command_line().unwrap(); + config.boot.as_mut().unwrap().command_line = command_line.to_owned(); + config.check().unwrap(); + assert_eq!( + config.kernel_command_line().unwrap(), + format!("{rendered} {command_line}") + ); + } +} + +#[test] +fn mapping_table_is_the_exact_export_projection_counted_by_the_boot_token() { + let mut plan = export("/work"); + plan.children.push(ExportChild { + root: missing("writable-export"), + writable: true, + ..plan.children[0].clone() + }); + plan.binds.push(Bind { + child: 1, + source: String::new(), + target: "/zother".to_owned(), + read_only: false, + identity: None, + }); + plan.binds.push(Bind { + source: "nested/file".to_owned(), + target: "/work/nested/file".to_owned(), + ..plan.binds[0].clone() + }); + let mut config = VmConfig { + filesystem: Some(plan.clone()), + boot: Some(Boot { + command_line: String::new(), + }), + ..blockless() + }; + let table = config.mapping_table(); + assert_eq!(table, plan.guest_table()); + assert_eq!( + table, + vec![ + crate::protocol::maps::HostMap { + source: "0".to_owned(), + target: "/work".to_owned(), + read_only: true, + }, + crate::protocol::maps::HostMap { + source: "1".to_owned(), + target: "/zother".to_owned(), + read_only: false, + }, + crate::protocol::maps::HostMap { + source: "0/nested/file".to_owned(), + target: "/work/nested/file".to_owned(), + read_only: true, + }, + ] + ); + let command_line = config.kernel_command_line().unwrap(); + assert_eq!( + parse_host_mapping_count(command_line.as_bytes()).unwrap(), + u32::try_from(table.len()).unwrap() + ); + config.processors = 0; + assert!(config.check().is_err()); + assert_eq!(config.mapping_table(), table); + config.processors = 1; + config.filesystem = None; + assert!(config.mapping_table().is_empty()); + let command_line = config.kernel_command_line().unwrap(); + assert!(!cmdline::init_tokens(command_line.as_bytes()).any(|token| token.name == b"nvx_maps")); +} + +#[test] +fn public_maps_codec_keeps_the_protocol_root_reexports_usable() { + let table: Vec = export("/work").guest_table(); + let payloads = crate::protocol::maps::encode_maps_payloads(&table, 1024).unwrap(); + let decoded: crate::protocol::MapsPayload = + crate::protocol::maps::decode_maps_payload(&payloads[0]).unwrap(); + assert_eq!(decoded.maps, table); + let payloads = crate::protocol::encode_maps_payloads(&decoded.maps, 1024).unwrap(); + let decoded: crate::protocol::maps::MapsPayload = + crate::protocol::decode_maps_payload(&payloads[0]).unwrap(); + assert_eq!(decoded.maps, table); +} diff --git a/nvx-rs/src/host/launch.rs b/nvx-rs/src/host/launch.rs index 713fa1428..27f5c786d 100644 --- a/nvx-rs/src/host/launch.rs +++ b/nvx-rs/src/host/launch.rs @@ -21,6 +21,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use super::artifacts::Artifacts; +use super::config::COMMAND_LINE_BUDGET; use super::filesystem::ExportPlan; use super::network::{self, NetworkPolicy}; use crate::error::{Error, Result}; @@ -31,8 +32,6 @@ use crate::protocol::cmdline::{self, Parameter}; pub use super::config::DEFAULT_GUEST_NETWORK; -/// Bytes of the x86 kernel command line available to caller tokens, including the NUL. -pub const COMMAND_LINE_BUDGET: usize = 1024; /// Longest Windows process command line, in UTF-16 code units, including the terminating NUL. /// /// OpenVMM's command line is held to it on every host, so a policy behaves alike on all of them. diff --git a/nvx-rs/src/protocol/mod.rs b/nvx-rs/src/protocol/mod.rs index 5d36700a3..ce2f3c317 100644 --- a/nvx-rs/src/protocol/mod.rs +++ b/nvx-rs/src/protocol/mod.rs @@ -45,7 +45,7 @@ pub mod cmdline; mod exec; pub mod export; mod features; -mod maps; +pub mod maps; pub mod nvxs; mod outer; pub mod root; From a967f69a8f8166dffe9c175d110ed2ec00e2cfa3 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 08:13:25 -0800 Subject: [PATCH 18/29] doc: describe caller boot tokens and mapping queries Document the reserved boot-token names, unchanged caller-owned tokens, pure mapping table, public map codec and configuration command-line budget so the crate specification matches the configuration checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0aa8163c-8d27-4d54-be61-d9b23c1bc15c --- nvx-rs/README.md | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 87bdb30df..4e994e2c6 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -504,19 +504,39 @@ needs one, and a VM that a snapshot restores takes none. `VmConfig::kernel_command_line` checks the configuration and requires `Boot`, then renders the root's tokens and settings, the view's tokens, and `nvx_maps` -when an export exists, followed by `Boot.command_line` unchanged. It neither -reads the host nor checks or rewrites caller text. A restore has no kernel -command line from this method and fails with kind `Unsupported`. +when an export exists, followed by accepted `Boot.command_line` text unchanged. +It reads nothing on the host. A restore has no kernel command line from this +method and fails with kind `Unsupported`. `VmConfig::check` checks the whole value without reading the host: it opens no path and checks no support of the host. It fails with an `nvx::Error` of kind `Unsupported` that names the first refusal: no processor or no memory; a layered root without an image or a scratch path, with a bound of an EROFS image or of 0, or with a RAM upper's cap outside 1 to 90 percent; a hostname that an activation -record refuses; or an export that `ExportPlan::check` refuses for the guest -targets that the root reserves. The network's policy is checked where it is -lowered, with the guest's address. This check does not inspect the boot's -command-line text. +record refuses; an export that `ExportPlan::check` refuses for the guest targets +that the root reserves; or caller boot tokens that use a reserved name. The +network's policy is checked where it is lowered, with the guest's address. + +The reserved boot names are the root's `nvx_layer`, `nvx_scratch`, +`nvx_overlay_upper`, `nvx_overlay_mode`, `nvx_scratch_journal`, +`nvx_first_partition_bound` and `nvx_ram_upper_percent`; the view's +`nvx_hostname` and `nvx_machine_id`; `nvx_maps`; and every name beginning +`virtfs_`. The check reads tokens as both Linux and the guest's init read them, +with dashes and underscores matching as Linux matches parameter names. +Reserved names stay reserved after `--` and when init's splitting exposes them +inside quoted text. Every other token stays caller-owned, including +`nvx_entrypoint`, `nvx_arg` and `nvx_share`. + +`VmConfig::mapping_table` returns exactly the configured +`ExportPlan::guest_table`, in its order, without reading host paths or +revalidating the plan. Without an export it returns an empty table and the +kernel command line omits `nvx_maps`; with an export the token counts those +entries. The table's `HostMap` and its wire codecs are accessible through +`nvx::protocol::maps`, and remain re-exported from `nvx::protocol`. + +`COMMAND_LINE_BUDGET`, in `nvx::host::config`, is the 1,024-byte x86 kernel +command-line budget, including the terminating NUL. The legacy launch checks +continue to use it. ### `nvx::host::artifacts` From f3479a0ef9aff601700c050e90a4201263bc3833 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 18:55:36 -0800 Subject: [PATCH 19/29] host: render cold boots from runtime and VM configuration Derive whole cold-boot arguments from Runtime and VmConfig, borrowing only the caller-chosen output names. Retain the console device and configured NIC, lower block devices by path, and check the complete Windows command-line limit on every host. Move the portable counting helpers to config while retaining the legacy launch paths and shared argument assembly. Add the reserved configuration-only check and document status-bearing guest power-off behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 76d8c9ca-6594-44b1-bc7b-810b64e7fe51 --- nvx-rs/README.md | 38 ++- nvx-rs/public-api/linux.txt | 5 + nvx-rs/public-api/windows.txt | 5 + nvx-rs/src/host/config/mod.rs | 341 ++++++++++++++++++++- nvx-rs/src/host/config/tests.rs | 516 +++++++++++++++++++++++++++++++- nvx-rs/src/host/launch.rs | 147 ++------- nvx-rs/src/host/launch/tests.rs | 66 +--- nvx-rs/src/host/runtime/mod.rs | 14 +- 8 files changed, 925 insertions(+), 207 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 4e994e2c6..5e86db181 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -124,7 +124,7 @@ changes only when the module's purpose does. | [`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::config`](#nvxhostconfig) | `VmConfig`: one VM configuration, checked and lowered into cold-boot kernel tokens without reading the host | +| [`nvx::host::config`](#nvxhostconfig) | `VmConfig`: one VM configuration, pure cold-boot kernel and OpenVMM arguments, and the portable command-line check | | [`nvx::host::artifacts`](#nvxhostartifacts) | Locating the OpenVMM executable and the guest artifacts that a VM runs, and their runtime identity | | [`nvx::host::launch`](#nvxhostlaunch) | `LaunchSpec` and `VmmLaunch`: the OpenVMM command line of a managed microVM, its checks, and the inputs that it shares with an adapter's launch | | [`nvx::host::network`](#nvxhostnetwork) | The one lowering of a network posture into OpenVMM options, for the CLI and external orchestrators | @@ -422,11 +422,13 @@ generation tells stale messages from foreign ones. `Runtime`: what every launch shares, the paths of the OpenVMM executable, guest kernel, and guest initramfs, their `RuntimeIdentity`, the hypervisor, an optional CPU profile, whether OpenVMM breaks away from the caller's Windows job -object, and the `LaunchOptions`. Those are what OpenVMM does when the guest -powers off, a `GuestPowerAction`, by default `Exit`, which ends the VM, or +object, and the `LaunchOptions`. Those are what OpenVMM does on a plain guest +power-off or hibernate event, a `GuestPowerAction`, by default `Exit`, or `Halt` or `Reset`, and how long OpenVMM waits for the host to authenticate on -the control console. The boot console is not among them: each VM attaches its -own. +the control console, in whole milliseconds from 1 to 60000. A power-off that +reports an exit status always exits OpenVMM with that status, regardless of +the selected action. The boot console is not among the options: each VM +attaches its own. `RuntimeIdentity` labels the three files with the `ArtifactIdentity` tokens that their install or staging metadata gives, such as `sha256:` and a digest, which @@ -538,6 +540,29 @@ 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 +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 +arguments, console attachment, the CPU profile, and every `LaunchOptions` value. +A configured NIC remains attached even under deny-all policy. A listener needs +its supplied console name; a disconnected attachment retains the device and +discards its output. 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. + +`VmConfig::check_command_line()` checks the configuration's known arguments on +every host, before any host change, with 2048 UTF-16 code units reserved for the +runtime's files, launch options, and VM output names. The reserve foresees file +and endpoint names of up to 260 code units, not arbitrary runtime paths or +CPU-profile strings. The whole renderer checks the complete actual command +line without a reserve too. `PROCESS_COMMAND_LINE_LIMIT`, +`process_command_line_units` and `fit_process_command_line` now belong to +`nvx::host::config`; their existing `nvx::host::launch` paths remain usable, +with the same Windows quoting and terminating-NUL count. + ### `nvx::host::artifacts` `Artifacts` (also `nvx::Artifacts`): the OpenVMM executable, guest kernel, and @@ -577,7 +602,8 @@ specification's. `process_command_line_units` counts the Windows command line that runs a program with its arguments, in UTF-16 code units, as Windows quotes them, and `fit_process_command_line` checks it against `PROCESS_COMMAND_LINE_LIMIT`, on -every host, with the reserve that its caller keeps. +every host, with the reserve that its caller keeps. The helpers live in +`nvx::host::config` and remain re-exported here for existing callers. `LaunchSpec::check_process_command_line`, which a caller runs before it provisions an instance, keeps 1024 code units for what may change before the instance starts, whose own check keeps none. diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 2ddd37d4a..f405cac6b 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -1771,6 +1771,7 @@ field nvx::host::config::NetworkConfig::guest_network field nvx::host::config::NetworkConfig::policy struct nvx::host::config::NicConfig field nvx::host::config::NicConfig::guest_network +const nvx::host::config::PROCESS_COMMAND_LINE_LIMIT enum nvx::host::config::RootConfig variant nvx::host::config::RootConfig::Blockless variant nvx::host::config::RootConfig::Layered @@ -1785,15 +1786,19 @@ field nvx::host::config::UpperConfig::Scratch::path struct nvx::host::config::VmConfig field nvx::host::config::VmConfig::boot method nvx::host::config::VmConfig::check +method nvx::host::config::VmConfig::check_command_line field nvx::host::config::VmConfig::console field nvx::host::config::VmConfig::filesystem 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 +fn nvx::host::config::fit_process_command_line +fn nvx::host::config::process_command_line_units mod nvx::host::filesystem struct nvx::host::filesystem::Bind field nvx::host::filesystem::Bind::child diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index cd144a51d..78de1bbe8 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -694,6 +694,7 @@ field nvx::host::config::NetworkConfig::guest_network field nvx::host::config::NetworkConfig::policy struct nvx::host::config::NicConfig field nvx::host::config::NicConfig::guest_network +const nvx::host::config::PROCESS_COMMAND_LINE_LIMIT enum nvx::host::config::RootConfig variant nvx::host::config::RootConfig::Blockless variant nvx::host::config::RootConfig::Layered @@ -708,15 +709,19 @@ field nvx::host::config::UpperConfig::Scratch::path struct nvx::host::config::VmConfig field nvx::host::config::VmConfig::boot method nvx::host::config::VmConfig::check +method nvx::host::config::VmConfig::check_command_line field nvx::host::config::VmConfig::console field nvx::host::config::VmConfig::filesystem 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 +fn nvx::host::config::fit_process_command_line +fn nvx::host::config::process_command_line_units mod nvx::host::filesystem struct nvx::host::filesystem::Bind field nvx::host::filesystem::Bind::child diff --git a/nvx-rs/src/host/config/mod.rs b/nvx-rs/src/host/config/mod.rs index 14cb3e60a..0977578c2 100644 --- a/nvx-rs/src/host/config/mod.rs +++ b/nvx-rs/src/host/config/mod.rs @@ -1,15 +1,21 @@ //! The configuration of a VM, as one value that [`VmConfig::check`] checks without reading the //! host: the VM's processors and memory, its [`RootConfig`], the [`View`] that its workload has //! of it, its [`NetworkConfig`], the host paths that it exports, its [`ConsoleAttachment`], and -//! what only its cold boot takes, a [`Boot`]. +//! what only its cold boot takes, a [`Boot`]. [`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. +use std::ffi::{OsStr, OsString}; use std::ops::RangeInclusive; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::time::Duration; use super::filesystem::{ExportPlan, ReservedTargets}; -use super::network::NetworkPolicy; +use super::network::{self, NetworkPolicy}; +use super::runtime::{GuestPowerAction, Runtime}; use crate::activation::{self, Journal, MAX_HOSTNAME_BYTES, OverlayMode, View}; use crate::error::{Error, Result}; +use crate::hypervisor::Hypervisor; use crate::net::Ipv4Cidr; use crate::protocol::cmdline::{self, Parameter}; use crate::protocol::maps::HostMap; @@ -19,6 +25,14 @@ use crate::protocol::view; /// Bytes of the x86 kernel command line available to caller tokens, including the NUL. pub const COMMAND_LINE_BUDGET: usize = 1024; +/// Longest Windows process command line, in UTF-16 code units, including the terminating NUL. +/// +/// OpenVMM's command line is held to it on every host, so a policy behaves alike on all of them. +pub const PROCESS_COMMAND_LINE_LIMIT: usize = 32_767; + +/// Room for the runtime's files and the VM's output paths, as legacy adapter planning reserves. +pub(crate) const OPENVMM_COMMAND_LINE_RESERVE: usize = 2048; + /// The guest address of a VM's NIC at a cold boot whose configuration names none, in the form /// that [`Ipv4Cidr::parse`] reads. pub const DEFAULT_GUEST_NETWORK: &str = "10.0.0.2/24"; @@ -188,6 +202,171 @@ impl VmConfig { Ok(tokens.join(" ")) } + /// Renders a cold boot's complete OpenVMM arguments from this configuration and `runtime`. + /// + /// `endpoint`, `report` and `console_endpoint` are already chosen output names, not launch + /// settings: this method creates no directory, file, listener, or process, and uses paths as + /// given. A [`ConsoleAttachment::Listener`] needs a nonempty `console_endpoint`; a + /// [`ConsoleAttachment::Disconnected`] needs none and retains the console device with a + /// discarded attachment. The control endpoint always listens and authenticates from stdin. + /// 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 + /// lifecycle nor workload-identity flags are rendered; the caller owns its guest tokens. + /// `runtime.windows_breakaway` is process-launch behavior, not an OpenVMM argument. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a refused + /// configuration, no [`Boot`], empty or NUL-containing required names or runtime paths, an + /// invalid authentication timeout, or a complete command line beyond the Windows limit. + /// The complete limit check runs on every host, without a reserve. + pub fn openvmm_arguments( + &self, + runtime: &Runtime, + endpoint: &str, + report: &Path, + console_endpoint: Option<&str>, + ) -> Result> { + let command_line = self.kernel_command_line()?; + check_endpoint(endpoint, "endpoint")?; + for (name, path) in [ + ("Runtime.openvmm", runtime.openvmm.as_path()), + ("Runtime.kernel", runtime.kernel.as_path()), + ("Runtime.initramfs", runtime.initramfs.as_path()), + ("report", report), + ] { + if path.as_os_str().is_empty() || path.as_os_str().to_string_lossy().contains('\0') { + return Err(Error::unsupported(format!( + "{name} must name a path without a NUL" + ))); + } + } + let console = match (self.console, console_endpoint) { + (ConsoleAttachment::Listener, Some(endpoint)) => { + check_endpoint(endpoint, "console_endpoint")?; + format!("listen={endpoint}") + } + (ConsoleAttachment::Disconnected, None) => "none".to_owned(), + (ConsoleAttachment::Listener, None) => { + return Err(Error::unsupported( + "a console listener needs console_endpoint", + )); + } + (ConsoleAttachment::Disconnected, Some(_)) => { + return Err(Error::unsupported( + "a disconnected console takes no console_endpoint", + )); + } + }; + let mut arguments = self.device_arguments()?; + arguments.extend(boot_arguments( + runtime.hypervisor, + self.memory_mib, + &runtime.kernel, + &runtime.initramfs, + &command_line, + )); + arguments.extend(console_arguments(&console, endpoint, report)); + if let Some(profile) = &runtime.cpu_profile { + if profile.is_empty() { + return Err(Error::unsupported("Runtime.cpu_profile must not be empty")); + } + arguments.extend(["--cpu-profile".into(), profile.as_str().into()]); + } + let shutdown = match runtime.options.guest_shutdown { + GuestPowerAction::Exit => "exit", + GuestPowerAction::Halt => "halt", + GuestPowerAction::Reset => "reset", + }; + arguments.extend(["--guest-shutdown-action".into(), shutdown.into()]); + if let Some(timeout) = runtime.options.control_auth_timeout { + if timeout < Duration::from_millis(1) + || timeout > Duration::from_secs(60) + || timeout.subsec_nanos() % 1_000_000 != 0 + { + return Err(Error::unsupported( + "LaunchOptions.control_auth_timeout must be whole milliseconds from 1 to 60000", + )); + } + arguments.extend([ + "--microvm-control-auth-timeout-ms".into(), + timeout.as_millis().to_string().into(), + ]); + } + check_argument_nuls(&arguments)?; + fit_process_command_line(runtime.openvmm.as_os_str(), &arguments, 0)?; + Ok(arguments) + } + + /// Checks the configuration's part of a cold boot's OpenVMM command line on every host, + /// without reading or changing the host, with 2048 UTF-16 code units reserved for the + /// runtime's files, launch options, and the VM's output paths. + /// + /// 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. + /// 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 + /// configuration or no [`Boot`], an unlowerable network policy, a NUL-containing argument, + /// or insufficient room for the reserve. + pub fn check_command_line(&self) -> Result<()> { + let command_line = self.kernel_command_line()?; + let mut arguments = self.device_arguments()?; + arguments.extend([ + "--memory".into(), + format!("{}M", self.memory_mib).into(), + "--cmdline".into(), + command_line.into(), + "--virtio-console".into(), + match self.console { + ConsoleAttachment::Listener => "listen=", + ConsoleAttachment::Disconnected => "none", + } + .into(), + ]); + check_argument_nuls(&arguments)?; + fit_arguments(&arguments, OPENVMM_COMMAND_LINE_RESERVE) + } + + /// Renders only the configuration's devices; no runtime or output name is needed. + fn device_arguments(&self) -> Result> { + let mut arguments = vec![ + "--machine".into(), + "microvm".into(), + "--processors".into(), + self.processors.to_string().into(), + ]; + if let RootConfig::Layered(root) = &self.root { + let mut image = OsString::from("distro:file:"); + image.push(&root.image); + image.push(",ro"); + arguments.extend(["--microvm-sandbox-block".into(), image]); + if let UpperConfig::Scratch { path, .. } = &root.upper { + let mut scratch = OsString::from("scratch:file:"); + scratch.push(path); + arguments.extend(["--microvm-sandbox-block".into(), scratch]); + } + } + if let Some(network) = &self.network { + let guest_network = network.guest_network.map_or_else( + || DEFAULT_GUEST_NETWORK.to_owned(), + |address| address.to_string(), + ); + let mut nic = network::openvmm_arguments(Some(&network.policy), &guest_network)?; + if nic.is_empty() { + // A configured NIC remains present even under the deny-all posture. + nic = network::template_arguments(&guest_network); + } + arguments.extend(nic.into_iter().map(OsString::from)); + } + if let Some(export) = &self.filesystem { + arguments.extend(export.openvmm_arguments().into_iter().map(OsString::from)); + } + Ok(arguments) + } + /// Checks the whole configuration without reading the host: it opens no path, and checks no /// support of the host. /// @@ -298,5 +477,161 @@ impl LayeredRoot { } } +/// Common cold-boot inputs, shared with the legacy VmmLaunch without narrowing its public types. +pub(crate) fn boot_arguments( + hypervisor: Hypervisor, + memory_mib: u64, + kernel: &Path, + initrd: &Path, + kernel_command_line: &str, +) -> Vec { + let memory = format!("{memory_mib}M"); + [ + OsStr::new("--single-process"), + OsStr::new("--hypervisor"), + OsStr::new(hypervisor.as_str()), + OsStr::new("--memory"), + OsStr::new(&memory), + OsStr::new("--kernel"), + kernel.as_os_str(), + OsStr::new("--initrd"), + initrd.as_os_str(), + OsStr::new("--cmdline"), + OsStr::new(kernel_command_line), + ] + .map(OsStr::to_os_string) + .into() +} + +/// Common console, control authentication, and report arguments of a cold launch. +pub(crate) fn console_arguments(console: &str, endpoint: &str, report: &Path) -> Vec { + let control = format!("listen={endpoint}"); + [ + OsStr::new("--virtio-console"), + OsStr::new(console), + OsStr::new("--microvm-control-console"), + OsStr::new(&control), + OsStr::new("--microvm-control-auth-stdin"), + OsStr::new("--microvm-report"), + report.as_os_str(), + ] + .map(OsStr::to_os_string) + .into() +} + +fn check_endpoint(endpoint: &str, name: &str) -> Result<()> { + if endpoint.is_empty() || endpoint.contains('\0') { + return Err(Error::unsupported(format!( + "{name} must name a nonempty endpoint without a NUL", + ))); + } + Ok(()) +} + +fn check_argument_nuls(arguments: &[OsString]) -> Result<()> { + if arguments + .iter() + .any(|argument| argument.to_string_lossy().contains('\0')) + { + return Err(Error::unsupported( + "OpenVMM's arguments must not contain a NUL", + )); + } + Ok(()) +} + +/// Returns the length in UTF-16 code units, including the terminating NUL, of the Windows command +/// line that runs `program` with `arguments`, quoted the way the Windows platform quotes it: the +/// program always in quotes, and each argument in quotes if it is empty or holds a space or a +/// tab, with a backslash before each quote that it holds and a backslash before each backslash +/// that precedes a quote, its closing quote included, and one space between them. +/// +/// It is the length that [`PROCESS_COMMAND_LINE_LIMIT`] bounds, whatever the host: +/// [`fit_process_command_line`] checks it. +pub fn process_command_line_units(program: &OsStr, arguments: &[OsString]) -> usize { + argument_units(program, true) + arguments_units(arguments) +} + +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) unless the Windows command +/// line that runs `program`, such as OpenVMM, with `arguments`, as [`process_command_line_units`] +/// counts it, leaves `reserve` code units of [`PROCESS_COMMAND_LINE_LIMIT`] unused: none for the +/// complete command line of a launch about to spawn OpenVMM, and some for what may change before +/// then, such as the paths of the artifacts. +/// +/// The error says how long the command line is, and how to shorten it. +pub fn fit_process_command_line( + program: &OsStr, + arguments: &[OsString], + reserve: usize, +) -> Result<()> { + let length = process_command_line_units(program, arguments); + let held = if reserve == 0 { + String::new() + } else { + format!(", with {reserve} held in reserve") + }; + fit("OpenVMM's command line", length, reserve, &held) +} + +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) unless OpenVMM's +/// `arguments`, which a plan renders, without the program and the rest of the launch, leave +/// `reserve` code units of [`PROCESS_COMMAND_LINE_LIMIT`] to that rest. +pub(crate) fn fit_arguments(arguments: &[OsString], reserve: usize) -> Result<()> { + let held = format!(", with {reserve} held in reserve for the rest of the launch"); + fit( + "OpenVMM's arguments", + arguments_units(arguments), + reserve, + &held, + ) +} + +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), saying that `subject` +/// would take `length` code units, `held` describing `reserve`, unless they leave `reserve` of +/// [`PROCESS_COMMAND_LINE_LIMIT`] unused. +fn fit(subject: &str, length: usize, reserve: usize, held: &str) -> Result<()> { + if length.saturating_add(reserve) <= PROCESS_COMMAND_LINE_LIMIT { + return Ok(()); + } + Err(Error::unsupported(format!( + "{subject} would take {length} of the {PROCESS_COMMAND_LINE_LIMIT} characters that a \ + Windows process command line allows{held}; map, hide, or allow fewer or shorter \ + filesystem paths, or use fewer network rules" + ))) +} + +/// Returns the length in UTF-16 code units of `arguments` on a Windows command line, quoted as +/// [`process_command_line_units`] quotes them, each with the space before it. +pub(crate) fn arguments_units(arguments: &[OsString]) -> usize { + arguments + .iter() + .map(|argument| argument_units(argument, false)) + .sum() +} + +/// Returns the length in UTF-16 code units of `argument` on a Windows command line, quoted as +/// [`process_command_line_units`] quotes it, always if it is the `program`, with one separating +/// space, or the terminating NUL after the last argument. +fn argument_units(argument: &OsStr, program: bool) -> usize { + let text = argument.to_string_lossy(); + let quoted = program || text.is_empty() || text.contains([' ', '\t']); + let mut length = 1 + text.encode_utf16().count() + if quoted { 2 } else { 0 }; + let mut backslashes = 0; + for character in text.chars() { + match character { + '\\' => backslashes += 1, + '"' => { + length += backslashes + 1; + backslashes = 0; + } + _ => backslashes = 0, + } + } + if quoted { + length += backslashes; + } + length +} + #[cfg(test)] mod tests; diff --git a/nvx-rs/src/host/config/tests.rs b/nvx-rs/src/host/config/tests.rs index 9a9a33614..61efeb299 100644 --- a/nvx-rs/src/host/config/tests.rs +++ b/nvx-rs/src/host/config/tests.rs @@ -9,7 +9,8 @@ use crate::activation::MachineIdMode; use crate::host::artifacts::Artifacts; use crate::host::filesystem::{Bind, ExportChild, Owner}; use crate::host::launch::LaunchSpec; -use crate::host::network::Access; +use crate::host::network::{Access, EgressRule, Protocol}; +use crate::host::runtime::{ArtifactIdentity, LaunchOptions, RuntimeIdentity}; use crate::host::sandbox::{SandboxDistro, SandboxPlan, SandboxSettings, SandboxUpper}; use crate::hypervisor::Hypervisor; use crate::protocol::{cmdline, parse_host_mapping_count}; @@ -610,3 +611,516 @@ fn public_maps_codec_keeps_the_protocol_root_reexports_usable() { crate::protocol::decode_maps_payload(&payloads[0]).unwrap(); assert_eq!(decoded.maps, table); } + +fn runtime() -> Runtime { + let artifact = ArtifactIdentity::new("fixture").unwrap(); + Runtime { + openvmm: missing("openvmm"), + kernel: missing("vmlinux"), + initramfs: missing("initramfs.cpio.gz"), + identity: RuntimeIdentity::new(artifact.clone(), artifact.clone(), artifact), + hypervisor: Hypervisor::Kvm, + cpu_profile: None, + windows_breakaway: false, + options: LaunchOptions::default(), + } +} + +fn cold(config: VmConfig) -> VmConfig { + VmConfig { + boot: Some(Boot { + command_line: "quiet nvx_entrypoint=/bin/work nvx_arg=\"a b\"".to_owned(), + }), + ..config + } +} + +fn values<'a>(arguments: &'a [OsString], option: &str) -> Vec<&'a OsStr> { + arguments + .windows(2) + .filter(|pair| pair[0] == option) + .map(|pair| pair[1].as_os_str()) + .collect() +} + +#[test] +fn cold_boot_arguments_select_the_configured_guest_without_managed_tokens() { + for hypervisor in [Hypervisor::Kvm, Hypervisor::Mshv, Hypervisor::Whp] { + for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { + let config = cold(VmConfig { + processors: 4, + memory_mib: u64::from(u32::MAX) + 1, + console, + ..blockless() + }); + let runtime = Runtime { + hypervisor, + ..runtime() + }; + let endpoint = "/missing/control.sock"; + let report = Path::new("/missing/outcome.json"); + let console_endpoint = + (console == ConsoleAttachment::Listener).then_some("/missing/console.sock"); + let arguments = config + .openvmm_arguments(&runtime, endpoint, report, console_endpoint) + .unwrap(); + let console_backend = if console_endpoint.is_some() { + "listen=/missing/console.sock" + } else { + "none" + }; + let expected: Vec = [ + OsStr::new("--machine"), OsStr::new("microvm"), + OsStr::new("--processors"), OsStr::new("4"), + OsStr::new("--single-process"), + OsStr::new("--hypervisor"), OsStr::new(hypervisor.as_str()), + OsStr::new("--memory"), OsStr::new("4294967296M"), + OsStr::new("--kernel"), runtime.kernel.as_os_str(), + OsStr::new("--initrd"), runtime.initramfs.as_os_str(), + OsStr::new("--cmdline"), + OsStr::new("nvx_hostname=626f78 nvx_machine_id=image quiet nvx_entrypoint=/bin/work nvx_arg=\"a b\""), + OsStr::new("--virtio-console"), OsStr::new(console_backend), + OsStr::new("--microvm-control-console"), OsStr::new("listen=/missing/control.sock"), + OsStr::new("--microvm-control-auth-stdin"), + OsStr::new("--microvm-report"), report.as_os_str(), + OsStr::new("--guest-shutdown-action"), OsStr::new("exit"), + ].into_iter().map(OsStr::to_os_string).collect(); + assert_eq!(arguments, expected); + for forbidden in [ + "--microvm-lifecycle", + "--microvm-workload-identity", + "--microvm-sandbox-block", + ] { + assert!(!arguments.iter().any(|argument| argument == forbidden)); + } + } + } +} + +#[test] +fn cold_boot_arguments_lower_layered_roots_networks_and_exports_by_path() { + for format in [ + RootImage::erofs(None), + RootImage::gpt(Some(Uuid::from_bytes([7; 16]))), + ] { + for upper in [ + UpperConfig::Ram { + max_memory_percent: 37, + }, + UpperConfig::Scratch { + path: missing("scratch with spaces.ext4"), + journal: Journal::Forbidden, + }, + ] { + for address in [None, Some(Ipv4Cidr::parse("172.16.8.2/24").unwrap())] { + let config = cold(VmConfig { + root: RootConfig::Layered(LayeredRoot { + image: missing("root with spaces.img"), + format, + gpt_first_partition_bound: (format.format == ImageFormat::Gpt) + .then_some(65536), + upper: upper.clone(), + overlay: OverlayMode::UntrustedLowers, + }), + network: Some(NetworkConfig { + guest_network: address, + policy: NetworkPolicy::new(Access::Deny) + .with_allow(EgressRule::to("192.0.2.0/24").on_port(Protocol::Tcp, 443)), + }), + filesystem: Some(export("/work")), + ..blockless() + }); + let arguments = config + .openvmm_arguments( + &runtime(), + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .unwrap(); + let mut image = OsString::from("distro:file:"); + image.push(missing("root with spaces.img")); + image.push(",ro"); + let mut blocks = vec![image]; + if let UpperConfig::Scratch { path, .. } = &upper { + let mut scratch = OsString::from("scratch:file:"); + scratch.push(path); + blocks.push(scratch); + } + assert_eq!( + values(&arguments, "--microvm-sandbox-block"), + blocks.iter().map(OsString::as_os_str).collect::>() + ); + assert_eq!( + values(&arguments, "--cmdline"), + [OsStr::new(&config.kernel_command_line().unwrap())] + ); + assert_eq!( + values(&arguments, "--net"), + [OsStr::new(if address.is_some() { + "172.16.8.2/24" + } else { + DEFAULT_GUEST_NETWORK + })] + ); + assert_eq!( + values(&arguments, "--network-profile"), + [OsStr::new("portable")] + ); + assert_eq!(values(&arguments, "--network-egress"), [OsStr::new("deny")]); + assert_eq!( + values(&arguments, "--network-egress-allow"), + [OsStr::new("192.0.2.0/24:tcp:443")] + ); + assert_eq!( + values(&arguments, "--mount-aggregate"), + [OsStr::new(crate::host::filesystem::GUEST_EXPORT)] + ); + let mut child = OsString::from("0,"); + child.push(missing("export")); + child.push(",ro"); + assert_eq!(values(&arguments, "--mount-child"), [child.as_os_str()]); + for forbidden in ["--microvm-lifecycle", "--microvm-workload-identity"] { + assert!(!arguments.iter().any(|argument| argument == forbidden)); + } + } + } + } +} + +#[test] +fn an_explicit_deny_all_network_keeps_its_nic() { + let mut config = cold(blockless()); + config.network = Some(NetworkConfig { + guest_network: None, + policy: NetworkPolicy::new(Access::Deny), + }); + let arguments = config + .openvmm_arguments( + &runtime(), + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .unwrap(); + assert_eq!( + values(&arguments, "--net"), + [OsStr::new(DEFAULT_GUEST_NETWORK)] + ); + for option in ["--network-egress", "--network-ingress", "--host-loopback"] { + assert_eq!(values(&arguments, option), [OsStr::new("deny")]); + } +} + +#[test] +fn cold_boot_arguments_render_every_launch_option_without_process_side_effects() { + for (action, text) in [ + (GuestPowerAction::Exit, "exit"), + (GuestPowerAction::Halt, "halt"), + (GuestPowerAction::Reset, "reset"), + ] { + for timeout in [ + None, + Some(Duration::from_millis(1)), + Some(Duration::from_millis(59999)), + Some(Duration::from_millis(60000)), + ] { + for profile in [None, Some("auto".to_owned())] { + let mut runtime = Runtime { + cpu_profile: profile.clone(), + options: LaunchOptions { + guest_shutdown: action, + control_auth_timeout: timeout, + }, + ..runtime() + }; + let config = cold(blockless()); + let render = |runtime: &Runtime| { + config + .openvmm_arguments( + runtime, + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .unwrap() + }; + let arguments = render(&runtime); + assert_eq!( + values(&arguments, "--guest-shutdown-action"), + [OsStr::new(text)] + ); + assert_eq!( + values(&arguments, "--cpu-profile"), + profile + .as_deref() + .map(OsStr::new) + .into_iter() + .collect::>() + ); + let milliseconds = timeout.map(|value| value.as_millis().to_string()); + assert_eq!( + values(&arguments, "--microvm-control-auth-timeout-ms"), + milliseconds + .as_deref() + .map(OsStr::new) + .into_iter() + .collect::>() + ); + runtime.windows_breakaway = true; + assert_eq!(arguments, render(&runtime)); + } + } + } +} + +#[test] +fn authentication_timeout_refuses_fractional_and_out_of_range_milliseconds() { + for timeout in [ + Duration::ZERO, + Duration::from_nanos(1), + Duration::from_micros(999), + Duration::from_nanos(1_000_001), + Duration::from_nanos(60_000_000_001), + Duration::from_millis(60001), + Duration::from_secs(u64::MAX), + ] { + let runtime = Runtime { + options: LaunchOptions { + control_auth_timeout: Some(timeout), + ..LaunchOptions::default() + }, + ..runtime() + }; + let error = cold(blockless()) + .openvmm_arguments( + &runtime, + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!( + error.to_string().contains("control_auth_timeout"), + "{error}" + ); + } +} + +#[test] +fn required_output_names_and_cold_boot_inputs_are_refused_without_host_io() { + let config = cold(blockless()); + let runtime = runtime(); + for (endpoint, report, console) in [ + ("", Path::new("/missing/report"), Some("/missing/console")), + ( + "/missing/control\0", + Path::new("/missing/report"), + Some("/missing/console"), + ), + ("/missing/control", Path::new(""), Some("/missing/console")), + ("/missing/control", Path::new("/missing/report"), None), + ("/missing/control", Path::new("/missing/report"), Some("")), + ( + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console\0"), + ), + ] { + assert_eq!( + config + .openvmm_arguments(&runtime, endpoint, report, console) + .unwrap_err() + .kind(), + ErrorKind::Unsupported + ); + } + assert_eq!( + blockless() + .openvmm_arguments( + &runtime, + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console") + ) + .unwrap_err() + .kind(), + ErrorKind::Unsupported + ); + assert_eq!( + blockless().check_command_line().unwrap_err().kind(), + ErrorKind::Unsupported + ); + let disconnected = cold(VmConfig { + console: ConsoleAttachment::Disconnected, + ..blockless() + }); + assert!( + disconnected + .openvmm_arguments( + &runtime, + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console") + ) + .is_err() + ); + let invalid = cold(VmConfig { + processors: 0, + ..blockless() + }); + assert!( + invalid + .openvmm_arguments( + &runtime, + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console") + ) + .is_err() + ); + assert!(invalid.check_command_line().is_err()); +} + +#[test] +fn configuration_command_lines_keep_the_same_reserve_on_every_host() { + let hidden = |padding| { + let mut plan = export("/work"); + let root = if cfg!(windows) { + r"D:\host\work" + } else { + "/host/work" + }; + plan.children[0].root = PathBuf::from(root).join("r".repeat(padding)); + plan.children[0].denied = (0..128) + .map(|index| { + plan.children[0] + .root + .join(format!("{index:03}-{}", "x".repeat(120))) + }) + .collect(); + plan.children[0].denied_identities = vec![None; 128]; + cold(VmConfig { + filesystem: Some(plan), + ..blockless() + }) + }; + hidden(80).check_command_line().unwrap(); + let reserved = hidden(95); + let error = reserved.check_command_line().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!( + error.to_string().contains("2048 held in reserve"), + "{error}" + ); + reserved + .openvmm_arguments( + &runtime(), + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .unwrap(); + let oversized = hidden(110); + assert_eq!( + oversized.check_command_line().unwrap_err().kind(), + ErrorKind::Unsupported + ); + assert_eq!( + oversized + .openvmm_arguments( + &runtime(), + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .unwrap_err() + .kind(), + ErrorKind::Unsupported + ); + let config = cold(blockless()); + config.check_command_line().unwrap(); + let runtime = Runtime { + openvmm: PathBuf::from("x".repeat(PROCESS_COMMAND_LINE_LIMIT)), + ..runtime() + }; + assert!( + config + .openvmm_arguments( + &runtime, + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .is_err() + ); +} + +#[test] +fn process_command_lines_are_measured_as_windows_quotes_them() { + let cases: [(&str, &[&str], &str); 8] = [ + ( + r"C:\Program Files\openvmm.exe", + &["--a", "b c", ""], + r#""C:\Program Files\openvmm.exe" --a "b c" """#, + ), + ("x", &[r#"say "hi""#], r#""x" "say \"hi\"""#), + ("x", &[r#"a"b"#], r#""x" a\"b"#), + ("x", &[r"dir\ with\"], r#""x" "dir\ with\\""#), + ("x", &[r"a\\b", "é"], r#""x" a\\b é"#), + ("", &[""], r#""" """#), + ("x", &["a\tb"], "\"x\" \"a\tb\""), + ("x", &["\u{1f680}"], "\"x\" \u{1f680}"), + ]; + for (program, arguments, line) in cases { + let arguments: Vec = arguments.iter().map(OsString::from).collect(); + assert_eq!( + process_command_line_units(OsStr::new(program), &arguments), + line.encode_utf16().count() + 1, + "{line}" + ); + } +} + +#[test] +fn a_process_command_line_fits_while_it_leaves_its_reserve() { + // Any program and arguments, such as an adapter's: a command line of the limit fits, + // one more code unit does not, and a reserve takes its part of the limit. + let arguments: Vec = ["--a", "b c"].map(OsString::from).into(); + let base = process_command_line_units(OsStr::new("/o"), &arguments); + let program = |extra: usize| OsString::from(format!("/o{}", "o".repeat(extra))); + let fitting = program(PROCESS_COMMAND_LINE_LIMIT - base); + assert_eq!( + process_command_line_units(&fitting, &arguments), + PROCESS_COMMAND_LINE_LIMIT + ); + fit_process_command_line(&fitting, &arguments, 0).unwrap(); + let held = fit_process_command_line(&fitting, &arguments, 1).unwrap_err(); + assert_eq!(held.kind(), ErrorKind::Unsupported); + assert!( + held.to_string() + .contains("would take 32767 of the 32767 characters"), + "{held}" + ); + assert!( + held.to_string().contains("with 1 held in reserve"), + "{held}" + ); + let longer = program(PROCESS_COMMAND_LINE_LIMIT - base + 1); + let error = fit_process_command_line(&longer, &arguments, 0).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + // A reserve past the limit leaves no room for any command line. + assert!(fit_process_command_line(OsStr::new("o"), &[], usize::MAX).is_err()); + // The arguments alone, which a plan renders, take what they take on the command line. + let alone = process_command_line_units(OsStr::new(""), &arguments) + - process_command_line_units(OsStr::new(""), &[]); + let reserve = PROCESS_COMMAND_LINE_LIMIT - alone; + fit_arguments(&arguments, reserve).unwrap(); + let held = fit_arguments(&arguments, reserve + 1).unwrap_err(); + assert!( + held.to_string() + .contains("held in reserve for the rest of the launch"), + "{held}" + ); +} diff --git a/nvx-rs/src/host/launch.rs b/nvx-rs/src/host/launch.rs index 27f5c786d..16e6c533c 100644 --- a/nvx-rs/src/host/launch.rs +++ b/nvx-rs/src/host/launch.rs @@ -16,12 +16,12 @@ //! [`PreparedLaunch::adapter_arguments`](super::sandbox::PreparedLaunch::adapter_arguments) holds //! an adapter's command line to the limit too. -use std::ffi::{OsStr, OsString}; +use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::time::Duration; use super::artifacts::Artifacts; -use super::config::COMMAND_LINE_BUDGET; +use super::config::{self, COMMAND_LINE_BUDGET}; use super::filesystem::ExportPlan; use super::network::{self, NetworkPolicy}; use crate::error::{Error, Result}; @@ -30,12 +30,11 @@ use crate::net::{is_valid_guest_network, is_valid_hostname}; use crate::protocol::WORKLOAD_ACCOUNT_TOKEN; use crate::protocol::cmdline::{self, Parameter}; -pub use super::config::DEFAULT_GUEST_NETWORK; - -/// Longest Windows process command line, in UTF-16 code units, including the terminating NUL. -/// -/// OpenVMM's command line is held to it on every host, so a policy behaves alike on all of them. -pub const PROCESS_COMMAND_LINE_LIMIT: usize = 32_767; +pub(crate) use super::config::fit_arguments; +pub use super::config::{ + DEFAULT_GUEST_NETWORK, PROCESS_COMMAND_LINE_LIMIT, fit_process_command_line, + process_command_line_units, +}; /// Part of that limit kept for what may change between provision and start, such as the /// configured OpenVMM and guest paths. const PROCESS_COMMAND_LINE_RESERVE: usize = 1024; @@ -348,7 +347,7 @@ impl VmmLaunch<'_> { /// plan: a quoted final path of up to `MAX_PATH` behind `\\?\` in place of a path of at least /// 4. A launch whose inputs are longer may still not fit, which its complete check refuses /// before OpenVMM starts. - pub const RESERVE: usize = 2048; + pub const RESERVE: usize = config::OPENVMM_COMMAND_LINE_RESERVE; /// Returns the arguments that name the launch's inputs, with the kernel command line /// `kernel_command_line`: the VM in one process, `--single-process`, on the hypervisor, @@ -359,30 +358,19 @@ impl VmmLaunch<'_> { /// OpenVMM reads from its standard input, `--microvm-control-auth-stdin`, and the outcome /// report, `--microvm-report`. pub fn arguments(&self, kernel_command_line: &str) -> Vec { - let memory = format!("{}M", self.memory_mib); - let control = format!("listen={}", self.endpoint); - [ - OsStr::new("--single-process"), - OsStr::new("--hypervisor"), - OsStr::new(self.hypervisor.as_str()), - OsStr::new("--memory"), - OsStr::new(&memory), - OsStr::new("--kernel"), - self.kernel.as_os_str(), - OsStr::new("--initrd"), - self.initrd.as_os_str(), - OsStr::new("--cmdline"), - OsStr::new(kernel_command_line), - OsStr::new("--virtio-console"), - OsStr::new("none"), - OsStr::new("--microvm-control-console"), - OsStr::new(&control), - OsStr::new("--microvm-control-auth-stdin"), - OsStr::new("--microvm-report"), - self.report.as_os_str(), - ] - .map(OsStr::to_os_string) - .into() + let mut arguments = config::boot_arguments( + self.hypervisor, + u64::from(self.memory_mib), + self.kernel, + self.initrd, + kernel_command_line, + ); + arguments.extend(config::console_arguments( + "none", + self.endpoint, + self.report, + )); + arguments } } @@ -474,98 +462,5 @@ pub fn timeout_problem(value: Duration, positive: bool) -> Option { }) } -/// Returns the length in UTF-16 code units, including the terminating NUL, of the Windows command -/// line that runs `program` with `arguments`, quoted the way the Windows platform quotes it: the -/// program always in quotes, and each argument in quotes if it is empty or holds a space or a -/// tab, with a backslash before each quote that it holds and a backslash before each backslash -/// that precedes a quote, its closing quote included, and one space between them. -/// -/// It is the length that [`PROCESS_COMMAND_LINE_LIMIT`] bounds, whatever the host: -/// [`fit_process_command_line`] checks it. -pub fn process_command_line_units(program: &OsStr, arguments: &[OsString]) -> usize { - argument_units(program, true) + arguments_units(arguments) -} - -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) unless the Windows command -/// line that runs `program`, such as OpenVMM, with `arguments`, as [`process_command_line_units`] -/// counts it, leaves `reserve` code units of [`PROCESS_COMMAND_LINE_LIMIT`] unused: none for the -/// complete command line of a launch about to spawn OpenVMM, and some for what may change before -/// then, such as the paths of the artifacts. -/// -/// The error says how long the command line is, and how to shorten it. -pub fn fit_process_command_line( - program: &OsStr, - arguments: &[OsString], - reserve: usize, -) -> Result<()> { - let length = process_command_line_units(program, arguments); - let held = if reserve == 0 { - String::new() - } else { - format!(", with {reserve} held in reserve") - }; - fit("OpenVMM's command line", length, reserve, &held) -} - -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) unless OpenVMM's -/// `arguments`, which a plan renders, without the program and the rest of the launch, leave -/// `reserve` code units of [`PROCESS_COMMAND_LINE_LIMIT`] to that rest. -pub(crate) fn fit_arguments(arguments: &[OsString], reserve: usize) -> Result<()> { - let held = format!(", with {reserve} held in reserve for the rest of the launch"); - fit( - "OpenVMM's arguments", - arguments_units(arguments), - reserve, - &held, - ) -} - -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), saying that `subject` -/// would take `length` code units, `held` describing `reserve`, unless they leave `reserve` of -/// [`PROCESS_COMMAND_LINE_LIMIT`] unused. -fn fit(subject: &str, length: usize, reserve: usize, held: &str) -> Result<()> { - if length.saturating_add(reserve) <= PROCESS_COMMAND_LINE_LIMIT { - return Ok(()); - } - Err(Error::unsupported(format!( - "{subject} would take {length} of the {PROCESS_COMMAND_LINE_LIMIT} characters that a \ - Windows process command line allows{held}; map, hide, or allow fewer or shorter \ - filesystem paths, or use fewer network rules" - ))) -} - -/// Returns the length in UTF-16 code units of `arguments` on a Windows command line, quoted as -/// [`process_command_line_units`] quotes them, each with the space before it. -fn arguments_units(arguments: &[OsString]) -> usize { - arguments - .iter() - .map(|argument| argument_units(argument, false)) - .sum() -} - -/// Returns the length in UTF-16 code units of `argument` on a Windows command line, quoted as -/// [`process_command_line_units`] quotes it, always if it is the `program`, with one separating -/// space, or the terminating NUL after the last argument. -fn argument_units(argument: &OsStr, program: bool) -> usize { - let text = argument.to_string_lossy(); - let quoted = program || text.is_empty() || text.contains([' ', '\t']); - let mut length = 1 + text.encode_utf16().count() + if quoted { 2 } else { 0 }; - let mut backslashes = 0; - for character in text.chars() { - match character { - '\\' => backslashes += 1, - '"' => { - length += backslashes + 1; - backslashes = 0; - } - _ => backslashes = 0, - } - } - if quoted { - length += backslashes; - } - length -} - #[cfg(test)] mod tests; diff --git a/nvx-rs/src/host/launch/tests.rs b/nvx-rs/src/host/launch/tests.rs index 371817c6f..2cc5fa0ca 100644 --- a/nvx-rs/src/host/launch/tests.rs +++ b/nvx-rs/src/host/launch/tests.rs @@ -2,6 +2,7 @@ use proptest::prelude::*; use super::*; use crate::ErrorKind; +use crate::host::config::arguments_units; use crate::host::filesystem::{Bind, ExportChild, Owner}; use crate::protocol::cmdline::tests::model; @@ -114,29 +115,6 @@ fn mappings_add_the_export_and_the_mapping_count() { ); } -#[test] -fn process_command_lines_are_measured_as_windows_quotes_them() { - let cases: [(&str, &[&str], &str); 5] = [ - ( - r"C:\Program Files\openvmm.exe", - &["--a", "b c", ""], - r#""C:\Program Files\openvmm.exe" --a "b c" """#, - ), - ("x", &[r#"say "hi""#], r#""x" "say \"hi\"""#), - ("x", &[r#"a"b"#], r#""x" a\"b"#), - ("x", &[r"dir\ with\"], r#""x" "dir\ with\\""#), - ("x", &[r"a\\b", "é"], r#""x" a\\b é"#), - ]; - for (program, arguments, line) in cases { - let arguments: Vec = arguments.iter().map(OsString::from).collect(); - assert_eq!( - process_command_line_units(OsStr::new(program), &arguments), - line.encode_utf16().count() + 1, - "{line}" - ); - } -} - #[test] fn oversized_process_command_lines_are_rejected() { let mut spec = spec(); @@ -200,48 +178,6 @@ fn launches_may_use_the_reserve_that_provisioning_keeps() { ); } -#[test] -fn a_process_command_line_fits_while_it_leaves_its_reserve() { - // Any program and arguments, such as an adapter's: a command line of the limit fits, - // one more code unit does not, and a reserve takes its part of the limit. - let arguments: Vec = ["--a", "b c"].map(OsString::from).into(); - let base = process_command_line_units(OsStr::new("/o"), &arguments); - let program = |extra: usize| OsString::from(format!("/o{}", "o".repeat(extra))); - let fitting = program(PROCESS_COMMAND_LINE_LIMIT - base); - assert_eq!( - process_command_line_units(&fitting, &arguments), - PROCESS_COMMAND_LINE_LIMIT - ); - fit_process_command_line(&fitting, &arguments, 0).unwrap(); - let held = fit_process_command_line(&fitting, &arguments, 1).unwrap_err(); - assert_eq!(held.kind(), ErrorKind::Unsupported); - assert!( - held.to_string() - .contains("would take 32767 of the 32767 characters"), - "{held}" - ); - assert!( - held.to_string().contains("with 1 held in reserve"), - "{held}" - ); - let longer = program(PROCESS_COMMAND_LINE_LIMIT - base + 1); - let error = fit_process_command_line(&longer, &arguments, 0).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - // A reserve past the limit leaves no room for any command line. - assert!(fit_process_command_line(OsStr::new("o"), &[], usize::MAX).is_err()); - // The arguments alone, which a plan renders, take what they take on the command line. - let alone = process_command_line_units(OsStr::new(""), &arguments) - - process_command_line_units(OsStr::new(""), &[]); - let reserve = PROCESS_COMMAND_LINE_LIMIT - alone; - fit_arguments(&arguments, reserve).unwrap(); - let held = fit_arguments(&arguments, reserve + 1).unwrap_err(); - assert!( - held.to_string() - .contains("held in reserve for the rest of the launch"), - "{held}" - ); -} - #[test] fn every_launch_names_its_own_inputs_alike() { let launch = VmmLaunch { diff --git a/nvx-rs/src/host/runtime/mod.rs b/nvx-rs/src/host/runtime/mod.rs index 90ba8edd2..9310b0342 100644 --- a/nvx-rs/src/host/runtime/mod.rs +++ b/nvx-rs/src/host/runtime/mod.rs @@ -47,22 +47,24 @@ pub struct Runtime { /// attaches its own. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct LaunchOptions { - /// What OpenVMM does when the guest powers off. + /// What OpenVMM does on a plain guest power-off or hibernate event. A guest power-off that + /// reports an exit status always exits OpenVMM with that status, regardless of this action. pub guest_shutdown: GuestPowerAction, /// How long OpenVMM waits for the host to authenticate on the control console, or `None` for - /// OpenVMM's default. + /// OpenVMM's default. The cold-boot renderer accepts whole milliseconds from 1 to 60000. pub control_auth_timeout: Option, } -/// What OpenVMM does when the guest powers off. +/// What OpenVMM does on a plain guest power-off or hibernate event. A power-off with an exit +/// status always exits OpenVMM with that status instead. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub enum GuestPowerAction { - /// OpenVMM exits, which ends the VM: the default, on which stopping a VM relies. + /// OpenVMM exits, which ends the VM: the default. #[default] Exit, - /// OpenVMM halts the VM and keeps running. + /// OpenVMM halts the VM and keeps running, unless the power-off reports an exit status. Halt, - /// OpenVMM restarts the guest. + /// OpenVMM restarts the guest, unless the power-off reports an exit status. Reset, } From a7f9eaa9d4a79af82ef772974db7866c0d4a187e Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 19:36:50 -0800 Subject: [PATCH 20/29] host: document cold-boot argument validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 76d8c9ca-6594-44b1-bc7b-810b64e7fe51 --- nvx-rs/src/host/config/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nvx-rs/src/host/config/mod.rs b/nvx-rs/src/host/config/mod.rs index 0977578c2..7b3582614 100644 --- a/nvx-rs/src/host/config/mod.rs +++ b/nvx-rs/src/host/config/mod.rs @@ -519,6 +519,7 @@ pub(crate) fn console_arguments(console: &str, endpoint: &str, report: &Path) -> .into() } +/// Refuses empty or NUL-containing caller-selected endpoint names. fn check_endpoint(endpoint: &str, name: &str) -> Result<()> { if endpoint.is_empty() || endpoint.contains('\0') { return Err(Error::unsupported(format!( @@ -528,6 +529,7 @@ fn check_endpoint(endpoint: &str, name: &str) -> Result<()> { Ok(()) } +/// Checks OpenVMM arguments for NULs that cannot be passed to a process. fn check_argument_nuls(arguments: &[OsString]) -> Result<()> { if arguments .iter() From dfe09bf29f319bd80ce190b1981b498a23f5c8a9 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 22:02:02 -0800 Subject: [PATCH 21/29] doc: retain both producer module descriptions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 98b02bc4-2d86-490f-8241-2234ccd39feb --- nvx-rs/README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 5bccb9654..5e86db181 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -124,10 +124,7 @@ changes only when the module's purpose does. | [`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 | -<<<<<<< HEAD | [`nvx::host::config`](#nvxhostconfig) | `VmConfig`: one VM configuration, pure cold-boot kernel and OpenVMM arguments, and the portable command-line check | -======= ->>>>>>> b93206ab3804792116ac4aae0451aa0f70892e8d | [`nvx::host::artifacts`](#nvxhostartifacts) | Locating the OpenVMM executable and the guest artifacts that a VM runs, and their runtime identity | | [`nvx::host::launch`](#nvxhostlaunch) | `LaunchSpec` and `VmmLaunch`: the OpenVMM command line of a managed microVM, its checks, and the inputs that it shares with an adapter's launch | | [`nvx::host::network`](#nvxhostnetwork) | The one lowering of a network posture into OpenVMM options, for the CLI and external orchestrators | @@ -482,7 +479,6 @@ 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. -<<<<<<< HEAD ### `nvx::host::config` `VmConfig`: the configuration of a VM as one value, its processors, its memory @@ -567,8 +563,6 @@ line without a reserve too. `PROCESS_COMMAND_LINE_LIMIT`, `nvx::host::config`; their existing `nvx::host::launch` paths remain usable, with the same Windows quoting and terminating-NUL count. -======= ->>>>>>> b93206ab3804792116ac4aae0451aa0f70892e8d ### `nvx::host::artifacts` `Artifacts` (also `nvx::Artifacts`): the OpenVMM executable, guest kernel, and From 506ce49c2da51b8bbaf2eb62bf480fbf4279f67e Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 22:07:14 -0800 Subject: [PATCH 22/29] host: reject unrepresentable cold-boot disk paths Reuse the pure disk-path validator for layered image and scratch arguments before either configuration preflight or whole rendering returns. Move the shared guard into config and retain its sandbox re-export, preserving diagnostics and host module layering. Add a regression for disk delimiters and bracket balance through both entry points, including valid literal paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 76d8c9ca-6594-44b1-bc7b-810b64e7fe51 --- nvx-rs/src/host/config/mod.rs | 31 ++++++++++++-- nvx-rs/src/host/config/tests.rs | 76 +++++++++++++++++++++++++++++++++ nvx-rs/src/host/sandbox/mod.rs | 22 +--------- 3 files changed, 105 insertions(+), 24 deletions(-) diff --git a/nvx-rs/src/host/config/mod.rs b/nvx-rs/src/host/config/mod.rs index 7b3582614..43a9b98e3 100644 --- a/nvx-rs/src/host/config/mod.rs +++ b/nvx-rs/src/host/config/mod.rs @@ -217,8 +217,9 @@ impl VmConfig { /// `runtime.windows_breakaway` is process-launch behavior, not an OpenVMM argument. /// /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a refused - /// configuration, no [`Boot`], empty or NUL-containing required names or runtime paths, an - /// invalid authentication timeout, or a complete command line beyond the Windows limit. + /// configuration, no [`Boot`], empty or NUL-containing required names or runtime paths, + /// block paths with disk-option delimiters or unbalanced brackets, an invalid authentication + /// timeout, or a complete command line beyond the Windows limit. /// The complete limit check runs on every host, without a reserve. pub fn openvmm_arguments( &self, @@ -310,7 +311,8 @@ impl VmConfig { /// /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a refused /// configuration or no [`Boot`], an unlowerable network policy, a NUL-containing argument, - /// or insufficient room for the reserve. + /// a block path that OpenVMM's disk syntax cannot hold literally, or insufficient room + /// for the reserve. pub fn check_command_line(&self) -> Result<()> { let command_line = self.kernel_command_line()?; let mut arguments = self.device_arguments()?; @@ -339,11 +341,13 @@ impl VmConfig { self.processors.to_string().into(), ]; if let RootConfig::Layered(root) = &self.root { + reject_disk_path(&root.image, &root.image.display().to_string())?; let mut image = OsString::from("distro:file:"); image.push(&root.image); image.push(",ro"); arguments.extend(["--microvm-sandbox-block".into(), image]); if let UpperConfig::Scratch { path, .. } = &root.upper { + reject_disk_path(path, &path.display().to_string())?; let mut scratch = OsString::from("scratch:file:"); scratch.push(path); arguments.extend(["--microvm-sandbox-block".into(), scratch]); @@ -477,6 +481,27 @@ impl LayeredRoot { } } +/// Refuses a disk path that OpenVMM's `--microvm-sandbox-block` value could not hold: one with +/// a comma, semicolon, or improperly nested brackets. The message shows `path` as `shown`. +pub(crate) fn reject_disk_path(path: &Path, shown: &str) -> Result<()> { + let depth = path + .to_string_lossy() + .bytes() + .try_fold(0_usize, |depth, byte| match byte { + b'[' => Some(depth + 1), + b']' => depth.checked_sub(1), + b',' | b';' => None, + _ => Some(depth), + }); + if depth != Some(0) { + return Err(Error::unsupported(format!( + "disk paths containing commas, semicolons, or unbalanced brackets are unsupported: \ + {shown}" + ))); + } + Ok(()) +} + /// Common cold-boot inputs, shared with the legacy VmmLaunch without narrowing its public types. pub(crate) fn boot_arguments( hypervisor: Hypervisor, diff --git a/nvx-rs/src/host/config/tests.rs b/nvx-rs/src/host/config/tests.rs index 61efeb299..04c3429b5 100644 --- a/nvx-rs/src/host/config/tests.rs +++ b/nvx-rs/src/host/config/tests.rs @@ -788,6 +788,82 @@ fn cold_boot_arguments_lower_layered_roots_networks_and_exports_by_path() { } } +#[test] +fn cold_boot_arguments_refuse_disk_syntax_in_image_and_scratch_paths() { + let runtime = runtime(); + for name in [ + "disk;direct", + "disk;create=1M", + "disk,ro", + "disk[", + "disk]", + "disk][", + ] { + for scratch in [false, true] { + let mut root = erofs(); + if scratch { + root.upper = UpperConfig::Scratch { + path: missing(name), + journal: Journal::Forbidden, + }; + } else { + root.image = missing(name); + } + let config = cold(layered(root)); + for result in [ + config.check_command_line(), + config + .openvmm_arguments( + &runtime, + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .map(|_| ()), + ] { + let error = result.unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!( + error + .to_string() + .contains("commas, semicolons, or unbalanced brackets"), + "{name}: {error}" + ); + } + } + } + + let image = missing("image with spaces[1].erofs"); + let scratch = missing("scratch with [nested[1]] brackets.ext4"); + let config = cold(layered(LayeredRoot { + image: image.clone(), + upper: UpperConfig::Scratch { + path: scratch.clone(), + journal: Journal::Forbidden, + }, + ..erofs() + })); + config.check_command_line().unwrap(); + let arguments = config + .openvmm_arguments( + &runtime, + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .unwrap(); + let blocks: Vec = [ + format!("distro:file:{},ro", image.display()), + format!("scratch:file:{}", scratch.display()), + ] + .map(OsString::from) + .into(); + assert_eq!( + values(&arguments, "--microvm-sandbox-block"), + blocks.iter().map(OsString::as_os_str).collect::>() + ); +} + #[test] fn an_explicit_deny_all_network_keeps_its_nic() { let mut config = cold(blockless()); diff --git a/nvx-rs/src/host/sandbox/mod.rs b/nvx-rs/src/host/sandbox/mod.rs index c602b6813..cfa0f54e9 100644 --- a/nvx-rs/src/host/sandbox/mod.rs +++ b/nvx-rs/src/host/sandbox/mod.rs @@ -75,6 +75,7 @@ pub use self::plan::{ pub use self::prepared::PreparedLaunch; use crate::error::{Error, ErrorCode, Result}; use crate::host::artifacts::require_existing; +pub(crate) use crate::host::config::reject_disk_path; use crate::host::filesystem::ExportPlan; use crate::net::is_valid_hostname; use crate::protocol::cmdline; @@ -162,27 +163,6 @@ pub fn require_file(path: &Path, description: &str) -> Result<()> { require_existing(path, description, ErrorCode::Unavailable) } -/// Refuses a disk path that OpenVMM's `--microvm-sandbox-block` value could not hold: one with -/// a comma, semicolon, or improperly nested brackets. The message shows `path` as `shown`. -pub(crate) fn reject_disk_path(path: &Path, shown: &str) -> Result<()> { - let depth = path - .to_string_lossy() - .bytes() - .try_fold(0_usize, |depth, byte| match byte { - b'[' => Some(depth + 1), - b']' => depth.checked_sub(1), - b',' | b';' => None, - _ => Some(depth), - }); - if depth != Some(0) { - return Err(refused(format!( - "disk paths containing commas, semicolons, or unbalanced brackets are unsupported: \ - {shown}" - ))); - } - Ok(()) -} - /// The extensions of the files that OpenVMM does not attach as the raw image of a sandbox block /// device: it opens a `.vhd`, `.vhdx`, or `.vmgs` file in its container format, refuses to write /// an `.iso` file, and refuses all four for the block devices of a snapshot. From ddfe74e9bd5b3d3f40b275a524d165b370b7803f Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 22:15:44 -0800 Subject: [PATCH 23/29] host: prepare checked cold VM launches Create an inspectable cold launch with its owned log, capability, outputs, arguments, and opened resources without starting a process. Refuse restores and missing boot data, preserve existing outputs, remove only newly created output on failure, and check the complete portable command-line limit. Keep the whole-configuration renderer internal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 98b02bc4-2d86-490f-8241-2234ccd39feb --- nvx-rs/README.md | 41 +++++-- nvx-rs/public-api/linux.txt | 10 +- nvx-rs/public-api/windows.txt | 10 +- nvx-rs/src/host/config/mod.rs | 15 ++- nvx-rs/src/host/mod.rs | 5 +- nvx-rs/src/host/snapshot.rs | 8 ++ nvx-rs/src/host/vm.rs | 168 +++++++++++++++++++++++++- nvx-rs/src/host/vm/tests.rs | 183 +++++++++++++++++++++++++++++ scripts/test-layout-allowlist.json | 2 +- 9 files changed, 419 insertions(+), 23 deletions(-) create mode 100644 nvx-rs/src/host/snapshot.rs diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 5e86db181..7a598001f 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -123,8 +123,9 @@ 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::config`](#nvxhostconfig) | `VmConfig`: one VM configuration, pure cold-boot kernel and OpenVMM arguments, and the portable command-line check | +| [`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) | The opaque snapshot input of a future VM restore | +| [`nvx::host::config`](#nvxhostconfig) | `VmConfig`: one VM configuration, pure cold-boot kernel arguments, and the portable command-line check | | [`nvx::host::artifacts`](#nvxhostartifacts) | Locating the OpenVMM executable and the guest artifacts that a VM runs, and their runtime identity | | [`nvx::host::launch`](#nvxhostlaunch) | `LaunchSpec` and `VmmLaunch`: the OpenVMM command line of a managed microVM, its checks, and the inputs that it shares with an adapter's launch | | [`nvx::host::network`](#nvxhostnetwork) | The one lowering of a network posture into OpenVMM options, for the CLI and external orchestrators | @@ -479,6 +480,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. + +`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` + +`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 @@ -540,9 +567,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 @@ -550,8 +577,8 @@ arguments, console attachment, the CPU profile, and every `LaunchOptions` value. A configured NIC remains attached even under deny-all policy. A listener needs its supplied console name; a disconnected attachment retains the device and discards its output. 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()`. `VmConfig::check_command_line()` checks the configuration's known arguments on every host, before any host change, with 2048 UTF-16 code units reserved for the diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index f405cac6b..84640b9cf 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -1793,7 +1793,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 @@ -2570,6 +2569,8 @@ field nvx::host::session::SessionError::Refused::0 variant nvx::host::session::SessionError::Reset variant nvx::host::session::SessionError::TimedOut fn nvx::host::session::connect +mod nvx::host::snapshot +struct nvx::host::snapshot::Snapshot mod nvx::host::store struct nvx::host::store::ArtifactIdentity method nvx::host::store::ArtifactIdentity::as_str @@ -3173,6 +3174,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 @@ -3181,6 +3188,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 78de1bbe8..092b575e8 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -716,7 +716,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 @@ -1493,6 +1492,8 @@ field nvx::host::session::SessionError::Refused::0 variant nvx::host::session::SessionError::Reset variant nvx::host::session::SessionError::TimedOut fn nvx::host::session::connect +mod nvx::host::snapshot +struct nvx::host::snapshot::Snapshot mod nvx::host::store struct nvx::host::store::ArtifactIdentity method nvx::host::store::ArtifactIdentity::as_str @@ -2095,6 +2096,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 @@ -2103,6 +2110,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 7b3582614..79c7d4312 100644 --- a/nvx-rs/src/host/config/mod.rs +++ b/nvx-rs/src/host/config/mod.rs @@ -1,9 +1,9 @@ //! The configuration of a VM, as one value that [`VmConfig::check`] checks without reading the //! host: the VM's processors and memory, its [`RootConfig`], the [`View`] that its workload has //! of it, its [`NetworkConfig`], the host paths that it exports, its [`ConsoleAttachment`], and -//! what only its cold boot takes, a [`Boot`]. [`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. +//! what only its cold boot takes, a [`Boot`]. 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::ffi::{OsStr, OsString}; use std::ops::RangeInclusive; @@ -211,8 +211,7 @@ impl VmConfig { /// discarded attachment. The control endpoint always listens and authenticates from stdin. /// 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. /// @@ -220,7 +219,7 @@ impl VmConfig { /// configuration, no [`Boot`], empty or NUL-containing required names or runtime paths, an /// invalid authentication timeout, 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, @@ -304,8 +303,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 6594e7cd6..d103cb1a8 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. [`snapshot`] names the +//! opaque input of a future restore; cold creation refuses it. //! - `cli` (feature `cli`) is the `nvx-rs` command-line interface, which runs microVMs and //! sandboxes, looks up and downloads artifacts, and compiles egress policies. //! @@ -88,6 +90,7 @@ pub mod runtime; pub mod sandbox; pub mod seal; pub mod session; +pub mod snapshot; mod spawn; pub mod store; pub mod supervisor; diff --git a/nvx-rs/src/host/snapshot.rs b/nvx-rs/src/host/snapshot.rs new file mode 100644 index 000000000..ee79b62bb --- /dev/null +++ b/nvx-rs/src/host/snapshot.rs @@ -0,0 +1,8 @@ +//! A snapshot from which a VM may later be restored. + +/// 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 09893b92b..fbdbd0f66 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -10,15 +10,21 @@ //! 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::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 +104,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), @@ -193,6 +195,164 @@ 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, 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)?; + 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..b63e70956 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,180 @@ 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(), + }), + } +} + +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); + } +} + +#[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 9577c9ff4..a6b816bfe 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -16,6 +16,6 @@ "nvx-rs/src/host/network/address.rs": 2, "nvx-rs/src/host/platform/windows/spawn.rs": 1, "nvx-rs/src/host/sandbox/plan.rs": 5, - "nvx-rs/src/host/vm.rs": 7 + "nvx-rs/src/host/vm.rs": 6 } } From 1819ddaf28065a02de8c34dd8aff9f5e81fe8156 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 22:57:19 -0800 Subject: [PATCH 24/29] host: refuse unsupported control-console kernel syntax Reject double quotes and standalone -- only in the cold renderer and command-line preflight, which always describe a control-console launch. Preserve caller-owned configuration checking and kernel projection, and replace only the cold-renderer fixture with a compatible token. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 76d8c9ca-6594-44b1-bc7b-810b64e7fe51 --- nvx-rs/src/host/config/mod.rs | 27 +++++++++++++--- nvx-rs/src/host/config/tests.rs | 56 +++++++++++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/nvx-rs/src/host/config/mod.rs b/nvx-rs/src/host/config/mod.rs index 43a9b98e3..1bbd940bc 100644 --- a/nvx-rs/src/host/config/mod.rs +++ b/nvx-rs/src/host/config/mod.rs @@ -219,7 +219,8 @@ impl VmConfig { /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a refused /// configuration, no [`Boot`], empty or NUL-containing required names or runtime paths, /// block paths with disk-option delimiters or unbalanced brackets, an invalid authentication - /// timeout, or a complete command line beyond the Windows limit. + /// timeout, 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( &self, @@ -228,7 +229,7 @@ impl VmConfig { report: &Path, console_endpoint: Option<&str>, ) -> Result> { - let command_line = self.kernel_command_line()?; + let command_line = self.control_kernel_command_line()?; check_endpoint(endpoint, "endpoint")?; for (name, path) in [ ("Runtime.openvmm", runtime.openvmm.as_path()), @@ -311,10 +312,10 @@ impl VmConfig { /// /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a refused /// configuration or no [`Boot`], an unlowerable network policy, a NUL-containing argument, - /// a block path that OpenVMM's disk syntax cannot hold literally, or insufficient room - /// for the reserve. + /// a block path that OpenVMM's disk syntax cannot hold literally, kernel text with double + /// quotes or the standalone `--` delimiter, or insufficient room for the reserve. pub fn check_command_line(&self) -> Result<()> { - let command_line = self.kernel_command_line()?; + let command_line = self.control_kernel_command_line()?; let mut arguments = self.device_arguments()?; arguments.extend([ "--memory".into(), @@ -332,6 +333,22 @@ impl VmConfig { fit_arguments(&arguments, OPENVMM_COMMAND_LINE_RESERVE) } + /// Renders kernel text without the double quotes or delimiter OpenVMM's control console refuses. + fn control_kernel_command_line(&self) -> Result { + let command_line = self.kernel_command_line()?; + if command_line.contains('"') + || command_line + .split_ascii_whitespace() + .any(|token| token == "--") + { + return Err(Error::unsupported( + "boot.command_line cannot contain double quotes or a standalone -- with the \ + OpenVMM control console", + )); + } + Ok(command_line) + } + /// Renders only the configuration's devices; no runtime or output name is needed. fn device_arguments(&self) -> Result> { let mut arguments = vec![ diff --git a/nvx-rs/src/host/config/tests.rs b/nvx-rs/src/host/config/tests.rs index 04c3429b5..8c02233e6 100644 --- a/nvx-rs/src/host/config/tests.rs +++ b/nvx-rs/src/host/config/tests.rs @@ -629,7 +629,7 @@ fn runtime() -> Runtime { fn cold(config: VmConfig) -> VmConfig { VmConfig { boot: Some(Boot { - command_line: "quiet nvx_entrypoint=/bin/work nvx_arg=\"a b\"".to_owned(), + command_line: "quiet nvx_entrypoint=/bin/work nvx_arg=ab".to_owned(), }), ..config } @@ -643,6 +643,58 @@ fn values<'a>(arguments: &'a [OsString], option: &str) -> Vec<&'a OsStr> { .collect() } +#[test] +fn cold_boot_command_lines_refuse_native_control_console_syntax() { + let runtime = runtime(); + for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { + for (caller, refused) in [ + (r#"nvx_arg="a b""#, true), + ("note=\"", true), + ("--", true), + ("quiet -- init", true), + ("quiet\t--\r\ninit", true), + ("nvx_arg='a'", false), + ("nvx_arg=--", false), + ("--not-a-delimiter", false), + ("--=1", false), + ("quiet\t--not-a-delimiter\nnote=ok", false), + ] { + let config = VmConfig { + boot: Some(Boot { + command_line: caller.to_owned(), + }), + console, + ..blockless() + }; + config.check().unwrap(); + let command_line = config.kernel_command_line().unwrap(); + assert!(command_line.ends_with(caller)); + let rendered = config.openvmm_arguments( + &runtime, + "/missing/control", + Path::new("/missing/report"), + (console == ConsoleAttachment::Listener).then_some("/missing/console"), + ); + if refused { + for result in [config.check_command_line(), rendered.map(|_| ())] { + let error = result.unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!( + error.to_string().contains("control console"), + "{caller:?}: {error}" + ); + } + } else { + config.check_command_line().unwrap(); + assert_eq!( + values(&rendered.unwrap(), "--cmdline"), + [OsStr::new(&command_line)] + ); + } + } + } +} + #[test] fn cold_boot_arguments_select_the_configured_guest_without_managed_tokens() { for hypervisor in [Hypervisor::Kvm, Hypervisor::Mshv, Hypervisor::Whp] { @@ -678,7 +730,7 @@ fn cold_boot_arguments_select_the_configured_guest_without_managed_tokens() { OsStr::new("--kernel"), runtime.kernel.as_os_str(), OsStr::new("--initrd"), runtime.initramfs.as_os_str(), OsStr::new("--cmdline"), - OsStr::new("nvx_hostname=626f78 nvx_machine_id=image quiet nvx_entrypoint=/bin/work nvx_arg=\"a b\""), + OsStr::new("nvx_hostname=626f78 nvx_machine_id=image quiet nvx_entrypoint=/bin/work nvx_arg=ab"), OsStr::new("--virtio-console"), OsStr::new(console_backend), OsStr::new("--microvm-control-console"), OsStr::new("listen=/missing/control.sock"), OsStr::new("--microvm-control-auth-stdin"), From 28e2e9591294e17d4c720dc7ca4851315085b60f Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 23:34:09 -0800 Subject: [PATCH 25/29] host: hand prepared cold launches to external callers Move the complete checked command and its owned log and inherited resources to the caller without reopening them. Add ownership-preserving platform conversions and a compiled cold-launch example using the existing spawn primitive. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4820e3a0-adc3-4382-8ccc-ae8b30d282d0 --- nvx-rs/Cargo.toml | 5 + nvx-rs/README.md | 20 +++- nvx-rs/examples/external-launch.rs | 166 +++++++++++++++++++++++++++++ nvx-rs/src/host/vm.rs | 136 ++++++++++++++++++++++- nvx-rs/src/host/vm/tests.rs | 99 ++++++++++++++++- 5 files changed, 420 insertions(+), 6 deletions(-) create mode 100644 nvx-rs/examples/external-launch.rs diff --git a/nvx-rs/Cargo.toml b/nvx-rs/Cargo.toml index 804ce2173..131b63937 100644 --- a/nvx-rs/Cargo.toml +++ b/nvx-rs/Cargo.toml @@ -132,6 +132,11 @@ path = "src/bin/nvx-rs.rs" required-features = ["cli"] doc = false +[[example]] +name = "external-launch" +path = "examples/external-launch.rs" +required-features = ["host"] + # The guest binaries. Their unit tests live in the library, so the binaries have no test harness. [[bin]] name = "nvx-console-pending" diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 7a598001f..2dc83990e 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -123,7 +123,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 and its fixed outputs, and a checked cold launch whose owned command can be handed to the caller's launcher | | [`nvx::host::snapshot`](#nvxhostsnapshot) | The opaque snapshot input of a future VM restore | | [`nvx::host::config`](#nvxhostconfig) | `VmConfig`: one VM configuration, pure cold-boot kernel arguments, and the portable command-line check | | [`nvx::host::artifacts`](#nvxhostartifacts) | Locating the OpenVMM executable and the guest artifacts that a VM runs, and their runtime identity | @@ -491,7 +491,7 @@ caller-owned inputs, and reports a cleanup failure with the original error. `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 +path. `inherit(resource)` 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, @@ -500,6 +500,22 @@ 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. +`Prepared::into_command()` moves the complete launch into `VmmCommand`: its +program, arguments, working directory, capability, outputs, inherited resources, +opened log and Windows breakaway setting. It neither reopens nor duplicates +resources, renders again nor rediscovers paths. `InheritedHandle` owns its +resource, converts from and back to `OwnedFd` on Unix or `OwnedHandle` on Windows, +and also accepts an opened `File`. The command documents the caller's spawn +contract, including standard streams, allowlisted inheritance, Linux detachment +and umask, Windows log job and breakaway, and cleanup of a failed child. + +The compiled [external-launch example](examples/external-launch.rs) consumes +this command, uses the existing public low-level spawn primitive without a +sandbox store, checks the cold guest's boot marker and shell response, then asks +it to exit cleanly. Build with `cargo build --locked --example external-launch` +and supply the backend, OpenVMM, kernel, initramfs, new VM directory, and the +three artifact SHA-256 digests from their installation metadata, in that order. + ### `nvx::host::snapshot` `Snapshot` is an opaque input of a future restore. `vm::create` currently diff --git a/nvx-rs/examples/external-launch.rs b/nvx-rs/examples/external-launch.rs new file mode 100644 index 000000000..00130add7 --- /dev/null +++ b/nvx-rs/examples/external-launch.rs @@ -0,0 +1,166 @@ +//! A caller-owned cold launch, using the existing low-level spawn primitive without a store. +//! +//! Run the compiled example with a backend, OpenVMM, kernel, initramfs, new VM directory, and +//! the three artifact SHA-256 digests from their installation metadata, in that order. + +use std::error::Error; +use std::io::{self, Write}; +use std::path::Path; +use std::time::{Duration, Instant}; + +use nvx::activation::{MachineIdMode, View}; +use nvx::host::config::{Boot, ConsoleAttachment, RootConfig, VmConfig}; +use nvx::host::platform::Transport; +use nvx::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; +use nvx::host::supervisor::{self, Inherited}; +use nvx::host::{report, session, vm}; +use nvx::hypervisor::Hypervisor; + +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args_os().collect(); + if args.len() != 9 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "usage: external-launch BACKEND OPENVMM KERNEL INITRAMFS VM-DIR \ + OPENVMM-SHA256 KERNEL-SHA256 INITRAMFS-SHA256", + ) + .into()); + } + let hypervisor = match args[1].to_str() { + Some("kvm") => Hypervisor::Kvm, + Some("mshv") => Hypervisor::Mshv, + Some("whp") => Hypervisor::Whp, + _ => return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid backend").into()), + }; + nvx::host::runtime::probe_hypervisor(hypervisor)?; + let identity = |index: usize| { + ArtifactIdentity::sha256(args[index].to_str().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "artifact digest is not Unicode", + ) + })?) + .map_err(Box::::from) + }; + let runtime = Runtime { + openvmm: args[2].clone().into(), + kernel: args[3].clone().into(), + initramfs: args[4].clone().into(), + identity: RuntimeIdentity::new(identity(6)?, identity(7)?, identity(8)?), + hypervisor, + cpu_profile: Some("auto".to_owned()), + windows_breakaway: false, + options: LaunchOptions::default(), + }; + let config = VmConfig { + processors: 1, + memory_mib: 256, + root: RootConfig::Blockless, + view: View { + hostname: b"external".to_vec(), + machine_id: MachineIdMode::Image, + }, + network: None, + filesystem: None, + console: ConsoleAttachment::Listener, + boot: Some(Boot { + command_line: "quiet loglevel=0".to_owned(), + }), + }; + vm::create_private_dir(Path::new(&args[5]))?; + let dir = vm::VmDir::open(Path::new(&args[5]))?; + let vm::VmmCommand { + program, + arguments, + working_dir, + capability, + outputs, + inherit, + log, + breakaway_from_job, + } = vm::create(&runtime, &config, None, &dir)?.into_command(); + let command = supervisor::VmmCommand { + program, + arguments, + working_dir, + breakaway_from_job, + inherit: inherit.iter().map(Inherited::new).collect(), + }; + let launched = supervisor::spawn(&command, capability.as_bytes(), log)?; + let result = (|| -> Result<(), Box> { + let process = launched.process()?; + let deadline = Instant::now() + Duration::from_secs(60); + let console = outputs.console.as_deref().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "no prepared boot console") + })?; + let mut console = session::connect( + console, + process.reference().pid, + process.reference().start_time, + deadline, + )?; + read_until(console.as_mut(), b"NVX-GUEST-BOOT-OK:", deadline)?; + console.write_all( + b"printf 'NVX-EXTERNAL-LAUNCH-OK\\n'; /sbin/nvx-exit 0\n", + Some(deadline.saturating_duration_since(Instant::now())), + )?; + read_until(console.as_mut(), b"\nNVX-EXTERNAL-LAUNCH-OK", deadline)?; + if !process.wait(deadline)? { + return Err(io::Error::new(io::ErrorKind::TimedOut, "VM did not exit").into()); + } + let outcome = report::read(&outputs.outcome) + .ok_or_else(|| io::Error::other("missing or invalid VM outcome"))?; + if outcome.category.as_deref() != Some("success") + || outcome.status_code != Some(0) + || outcome.teardown_complete != Some(true) + { + return Err( + io::Error::other(format!("VM did not shut down cleanly: {outcome:?}")).into(), + ); + } + Ok(()) + })(); + if let Err(error) = result { + if !launched.kill_and_wait() { + return Err( + io::Error::other(format!("{error}; could not reap the launched VM")).into(), + ); + } + return Err(error); + } + launched.detach(); + println!( + "external cold launch completed; outcome={}", + outputs.outcome.display() + ); + Ok(()) +} + +fn read_until(console: &mut dyn Transport, marker: &[u8], deadline: Instant) -> io::Result<()> { + let mut pending = Vec::new(); + let mut buffer = [0; 4096]; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "console marker not received", + )); + } + let count = console.read(&mut buffer, Some(remaining))?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "boot console closed", + )); + } + io::stdout().write_all(&buffer[..count])?; + pending.extend_from_slice(&buffer[..count]); + if pending.windows(marker.len()).any(|bytes| bytes == marker) { + return Ok(()); + } + if pending.len() > marker.len() { + pending.drain(..pending.len() - marker.len()); + } + } +} diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index fbdbd0f66..d7090a793 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -195,6 +195,109 @@ impl fmt::Debug for Capability { } } +/// An owned descriptor on Unix. +#[cfg(unix)] +type Owned = std::os::fd::OwnedFd; +/// An owned handle on Windows. +#[cfg(windows)] +type Owned = std::os::windows::io::OwnedHandle; +/// An owned file on hosts without descriptors or handles. +#[cfg(not(any(unix, windows)))] +type Owned = File; + +/// An already opened resource that a VMM inherits, owned until dropped or transferred. +/// +/// Conversions move the same descriptor or handle; they neither duplicate nor reopen it. +/// Borrowed raw descriptors and handles cannot transfer ownership into this type. +#[derive(Debug)] +pub struct InheritedHandle { + /// The owned platform resource. + handle: Owned, +} + +impl From for InheritedHandle { + fn from(file: File) -> Self { + Self { + handle: file.into(), + } + } +} + +#[cfg(unix)] +impl From for InheritedHandle { + fn from(handle: std::os::fd::OwnedFd) -> Self { + Self { handle } + } +} + +#[cfg(unix)] +impl From for std::os::fd::OwnedFd { + fn from(resource: InheritedHandle) -> Self { + resource.handle + } +} + +#[cfg(unix)] +impl std::os::fd::AsFd for InheritedHandle { + fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> { + std::os::fd::AsFd::as_fd(&self.handle) + } +} + +#[cfg(windows)] +impl From for InheritedHandle { + fn from(handle: std::os::windows::io::OwnedHandle) -> Self { + Self { handle } + } +} + +#[cfg(windows)] +impl From for std::os::windows::io::OwnedHandle { + fn from(resource: InheritedHandle) -> Self { + resource.handle + } +} + +#[cfg(windows)] +impl std::os::windows::io::AsHandle for InheritedHandle { + fn as_handle(&self) -> std::os::windows::io::BorrowedHandle<'_> { + std::os::windows::io::AsHandle::as_handle(&self.handle) + } +} + +/// A complete cold launch handed to the caller's launcher, with all its resources owned. +/// +/// The launcher supplies the capability on stdin through a pipe closed to writers before +/// spawning, and uses the opened log for stdout and stderr. It allows only the standard streams +/// and `inherit`, under their original descriptor or handle numbers, into the child. +/// +/// Preserve the launch contract of [`crate::host::supervisor::spawn`]: detach on Linux, clear +/// only the child's owner umask bits, and keep allowed descriptors close-on-exec in the caller; +/// on Windows, use an explicit handle list, restore temporary inheritability, start without a +/// console in the log-named job, and honor `breakaway_from_job`. Windows callers coordinate their +/// other spawns with the inheritance window. If setup fails after spawning, terminate and reap +/// only that child before returning an error. The caller journals recovery data and keeps the +/// VM directory and runtime inputs stable; this value starts no process or listener. +#[derive(Debug)] +pub struct VmmCommand { + /// The checked OpenVMM executable, as the runtime supplied it. + pub program: PathBuf, + /// The complete, checked arguments, without the executable. + pub arguments: Vec, + /// The VM directory in which OpenVMM runs. + pub working_dir: PathBuf, + /// The control credential, never written to disk by the library. + pub capability: Capability, + /// The already chosen output files and endpoints. + pub outputs: VmOutputs, + /// Owned resources allowed into the child besides its standard streams. + pub inherit: Vec, + /// The same private log that preparation opened. + pub log: File, + /// Whether Windows leaves the caller's job, which must permit breakaway. + pub breakaway_from_job: bool, +} + /// 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 @@ -211,7 +314,13 @@ pub struct Prepared { /// 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, + inherit: Vec, + /// The executable supplied by the checked runtime. + program: PathBuf, + /// The checked VM working directory. + working_dir: PathBuf, + /// The runtime's Windows job-breakaway setting. + breakaway_from_job: bool, } impl Prepared { @@ -228,8 +337,8 @@ impl Prepared { /// 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); + pub fn inherit(&mut self, resource: impl Into) { + self.inherit.push(resource.into()); } /// Returns the same opened log that creation made, for example to take a liveness claim. @@ -241,6 +350,24 @@ impl Prepared { pub fn arguments(&self) -> &[OsString] { &self.arguments } + + /// Transfers the complete checked launch to the caller's launcher. + /// + /// Moves the same opened log and inherited resources, without cloning, reopening, rendering + /// arguments again or rediscovering paths. Starts no process or listener. The caller follows + /// [`VmmCommand`]'s launch contract and may retain its capability and outputs for recovery. + pub fn into_command(self) -> VmmCommand { + VmmCommand { + program: self.program, + arguments: self.arguments, + working_dir: self.working_dir, + capability: self.capability, + outputs: self.outputs, + inherit: self.inherit, + log: self.log, + breakaway_from_job: self.breakaway_from_job, + } + } } /// Creates an inspectable cold launch in `dir`, without starting a process or binding endpoints. @@ -324,6 +451,9 @@ pub fn create( log, arguments, inherit: Vec::new(), + program: runtime.openvmm.clone(), + working_dir: dir.path.clone(), + breakaway_from_job: runtime.windows_breakaway, }), Err(error) => { drop(log); diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index b63e70956..351b15dff 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -539,11 +539,108 @@ fn the_log_and_inherited_resources_stay_open_without_reopening_their_paths() { 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(); + inherited_file(prepared.inherit.pop().unwrap()) + .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"); } +fn inherited_file(resource: InheritedHandle) -> File { + #[cfg(any(unix, windows))] + return File::from(Owned::from(resource)); + #[cfg(not(any(unix, windows)))] + resource.handle +} + +#[test] +fn a_command_moves_the_checked_launch_and_the_same_opened_resources() { + use std::io::Write; + + let (_directory, vm) = vm_dir(); + let mut runtime = cold_runtime(); + runtime.windows_breakaway = true; + let mut prepared = create(&runtime, &cold_config(), None, &vm).unwrap(); + let program = runtime.openvmm.clone(); + let arguments = prepared.arguments().as_ptr(); + let capability = *prepared.capability().as_bytes(); + let outputs = prepared.outputs().clone(); + let original_log = vm.path().join("original-log"); + fs::rename(&outputs.log, &original_log).unwrap(); + fs::write(&outputs.log, b"replacement log").unwrap(); + let input = vm.path().join("input"); + let original_input = vm.path().join("original-input"); + prepared.inherit(File::create(&input).unwrap()); + let resources = prepared.inherit.as_ptr(); + fs::rename(&input, &original_input).unwrap(); + fs::write(&input, b"replacement input").unwrap(); + #[cfg(unix)] + let log_handle = std::os::fd::AsRawFd::as_raw_fd(prepared.log()); + #[cfg(windows)] + let log_handle = std::os::windows::io::AsRawHandle::as_raw_handle(prepared.log()); + runtime.openvmm = "different-program".into(); + runtime.windows_breakaway = false; + + let mut command = prepared.into_command(); + assert_eq!(command.program, program); + assert_eq!(command.working_dir, vm.path()); + assert!(command.breakaway_from_job); + assert_eq!(command.arguments.as_ptr(), arguments); + assert_eq!(command.inherit.as_ptr(), resources); + assert_eq!(command.capability.as_bytes(), &capability); + assert_eq!(command.outputs, outputs); + #[cfg(unix)] + assert_eq!(std::os::fd::AsRawFd::as_raw_fd(&command.log), log_handle); + #[cfg(windows)] + assert_eq!( + std::os::windows::io::AsRawHandle::as_raw_handle(&command.log), + log_handle + ); + command.log.write_all(b"original log").unwrap(); + inherited_file(command.inherit.pop().unwrap()) + .write_all(b"original input") + .unwrap(); + assert_eq!(fs::read(original_log).unwrap(), b"original log"); + assert_eq!(fs::read(original_input).unwrap(), b"original input"); + assert_eq!(fs::read(&outputs.log).unwrap(), b"replacement log"); + assert_eq!(fs::read(input).unwrap(), b"replacement input"); + drop(command); + assert!(outputs.log.exists()); + assert!(!outputs.outcome.exists()); +} + +#[cfg(unix)] +#[test] +fn an_owned_descriptor_moves_into_and_out_of_an_inherited_handle() { + use std::io::Write; + use std::os::fd::{AsFd, AsRawFd, OwnedFd}; + + let file = tempfile::tempfile().unwrap(); + let number = file.as_raw_fd(); + let descriptor: OwnedFd = file.into(); + let resource: InheritedHandle = descriptor.into(); + assert_eq!(resource.as_fd().as_raw_fd(), number); + let descriptor: OwnedFd = resource.into(); + assert_eq!(descriptor.as_raw_fd(), number); + File::from(descriptor).write_all(b"still open").unwrap(); +} + +#[cfg(windows)] +#[test] +fn an_owned_windows_handle_moves_into_and_out_of_an_inherited_handle() { + use std::io::Write; + use std::os::windows::io::{AsHandle, AsRawHandle, OwnedHandle}; + + let file = tempfile::tempfile().unwrap(); + let number = file.as_raw_handle(); + let handle: OwnedHandle = file.into(); + let resource: InheritedHandle = handle.into(); + assert_eq!(resource.as_handle().as_raw_handle(), number); + let handle: OwnedHandle = resource.into(); + assert_eq!(handle.as_raw_handle(), number); + File::from(handle).write_all(b"still open").unwrap(); +} + #[test] fn creation_checks_the_complete_actual_command_line_at_the_windows_boundary() { let (_directory, vm) = vm_dir(); From 2a4a7379ab3a9bfb97995d3e445a70f60f412875 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 23:45:49 -0800 Subject: [PATCH 26/29] host: record the owned command API and compile its external example Use the public hypervisor re-export in the external consumer and record the new owned command in both generated platform API snapshots. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4820e3a0-adc3-4382-8ccc-ae8b30d282d0 --- nvx-rs/examples/external-launch.rs | 3 ++- nvx-rs/public-api/linux.txt | 11 +++++++++++ nvx-rs/public-api/windows.txt | 11 +++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/nvx-rs/examples/external-launch.rs b/nvx-rs/examples/external-launch.rs index 00130add7..c74646477 100644 --- a/nvx-rs/examples/external-launch.rs +++ b/nvx-rs/examples/external-launch.rs @@ -8,13 +8,13 @@ use std::io::{self, Write}; use std::path::Path; use std::time::{Duration, Instant}; +use nvx::Hypervisor; use nvx::activation::{MachineIdMode, View}; use nvx::host::config::{Boot, ConsoleAttachment, RootConfig, VmConfig}; use nvx::host::platform::Transport; use nvx::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; use nvx::host::supervisor::{self, Inherited}; use nvx::host::{report, session, vm}; -use nvx::hypervisor::Hypervisor; fn main() -> Result<(), Box> { let args: Vec<_> = std::env::args_os().collect(); @@ -136,6 +136,7 @@ fn main() -> Result<(), Box> { Ok(()) } +/// Prints console output through a marker, retaining only enough bytes to match across reads. fn read_until(console: &mut dyn Transport, marker: &[u8], deadline: Instant) -> io::Result<()> { let mut pending = Vec::new(); let mut buffer = [0; 4096]; diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 84640b9cf..f6f7ee88f 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -3174,10 +3174,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::InheritedHandle 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::into_command method nvx::host::vm::Prepared::log method nvx::host::vm::Prepared::outputs struct nvx::host::vm::VmDir @@ -3188,6 +3190,15 @@ field nvx::host::vm::VmOutputs::console field nvx::host::vm::VmOutputs::endpoint field nvx::host::vm::VmOutputs::log field nvx::host::vm::VmOutputs::outcome +struct nvx::host::vm::VmmCommand +field nvx::host::vm::VmmCommand::arguments +field nvx::host::vm::VmmCommand::breakaway_from_job +field nvx::host::vm::VmmCommand::capability +field nvx::host::vm::VmmCommand::inherit +field nvx::host::vm::VmmCommand::log +field nvx::host::vm::VmmCommand::outputs +field nvx::host::vm::VmmCommand::program +field nvx::host::vm::VmmCommand::working_dir fn nvx::host::vm::create fn nvx::host::vm::create_private_dir mod nvx::net diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index 092b575e8..ba40785ad 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -2096,10 +2096,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::InheritedHandle 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::into_command method nvx::host::vm::Prepared::log method nvx::host::vm::Prepared::outputs struct nvx::host::vm::VmDir @@ -2110,6 +2112,15 @@ field nvx::host::vm::VmOutputs::console field nvx::host::vm::VmOutputs::endpoint field nvx::host::vm::VmOutputs::log field nvx::host::vm::VmOutputs::outcome +struct nvx::host::vm::VmmCommand +field nvx::host::vm::VmmCommand::arguments +field nvx::host::vm::VmmCommand::breakaway_from_job +field nvx::host::vm::VmmCommand::capability +field nvx::host::vm::VmmCommand::inherit +field nvx::host::vm::VmmCommand::log +field nvx::host::vm::VmmCommand::outputs +field nvx::host::vm::VmmCommand::program +field nvx::host::vm::VmmCommand::working_dir fn nvx::host::vm::create fn nvx::host::vm::create_private_dir mod nvx::net From d1d803a70e88f1a7b71323f19d97a18234bad698 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 23:59:55 -0800 Subject: [PATCH 27/29] host: refuse unrepresentable native argument names Require Unicode path-based block arguments and distinct boot/control listeners, using the declared WHP runtime for ASCII-case-insensitive pipe names. Preserve configuration/kernel projection and the legacy disk-path guard; add no host I/O or canonicalization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 76d8c9ca-6594-44b1-bc7b-810b64e7fe51 --- nvx-rs/src/host/config/mod.rs | 40 ++++++++--- nvx-rs/src/host/config/tests.rs | 113 ++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 10 deletions(-) diff --git a/nvx-rs/src/host/config/mod.rs b/nvx-rs/src/host/config/mod.rs index 1bbd940bc..883993c75 100644 --- a/nvx-rs/src/host/config/mod.rs +++ b/nvx-rs/src/host/config/mod.rs @@ -209,6 +209,7 @@ impl VmConfig { /// given. A [`ConsoleAttachment::Listener`] needs a nonempty `console_endpoint`; a /// [`ConsoleAttachment::Disconnected`] needs none and retains the console device with a /// discarded attachment. The control endpoint always listens and authenticates from stdin. + /// Listener names must be distinct, with ASCII-case-insensitive comparison for WHP. /// The caller runs `runtime.openvmm` with these arguments. /// /// This whole-config entry point is temporarily public. SL2.8 makes it internal when @@ -218,9 +219,9 @@ impl VmConfig { /// /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a refused /// configuration, no [`Boot`], empty or NUL-containing required names or runtime paths, - /// block paths with disk-option delimiters or unbalanced brackets, an invalid authentication - /// timeout, kernel text with double quotes or the standalone `--` delimiter, or a complete - /// command line beyond the Windows limit. + /// duplicate listener names, block paths that are not Unicode or hold disk-option delimiters + /// or unbalanced brackets, an invalid authentication timeout, 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( &self, @@ -244,9 +245,17 @@ impl VmConfig { } } let console = match (self.console, console_endpoint) { - (ConsoleAttachment::Listener, Some(endpoint)) => { - check_endpoint(endpoint, "console_endpoint")?; - format!("listen={endpoint}") + (ConsoleAttachment::Listener, Some(console_endpoint)) => { + check_endpoint(console_endpoint, "console_endpoint")?; + if console_endpoint == endpoint + || (runtime.hypervisor == Hypervisor::Whp + && console_endpoint.eq_ignore_ascii_case(endpoint)) + { + return Err(Error::unsupported( + "boot and control console listeners require distinct endpoints", + )); + } + format!("listen={console_endpoint}") } (ConsoleAttachment::Disconnected, None) => "none".to_owned(), (ConsoleAttachment::Listener, None) => { @@ -312,8 +321,9 @@ impl VmConfig { /// /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a refused /// configuration or no [`Boot`], an unlowerable network policy, a NUL-containing argument, - /// a block path that OpenVMM's disk syntax cannot hold literally, kernel text with double - /// quotes or the standalone `--` delimiter, or insufficient room for the reserve. + /// a non-Unicode block path or one that OpenVMM's disk syntax cannot hold literally, kernel + /// text with double quotes or the standalone `--` delimiter, or insufficient room for the + /// reserve. pub fn check_command_line(&self) -> Result<()> { let command_line = self.control_kernel_command_line()?; let mut arguments = self.device_arguments()?; @@ -358,13 +368,23 @@ impl VmConfig { self.processors.to_string().into(), ]; if let RootConfig::Layered(root) = &self.root { - reject_disk_path(&root.image, &root.image.display().to_string())?; + let scratch = match &root.upper { + UpperConfig::Scratch { path, .. } => Some(path), + UpperConfig::Ram { .. } => None, + }; + for path in [Some(&root.image), scratch].into_iter().flatten() { + if path.to_str().is_none() { + return Err(Error::unsupported( + "block paths must be Unicode for OpenVMM's string arguments", + )); + } + reject_disk_path(path, &path.display().to_string())?; + } let mut image = OsString::from("distro:file:"); image.push(&root.image); image.push(",ro"); arguments.extend(["--microvm-sandbox-block".into(), image]); if let UpperConfig::Scratch { path, .. } = &root.upper { - reject_disk_path(path, &path.display().to_string())?; let mut scratch = OsString::from("scratch:file:"); scratch.push(path); arguments.extend(["--microvm-sandbox-block".into(), scratch]); diff --git a/nvx-rs/src/host/config/tests.rs b/nvx-rs/src/host/config/tests.rs index 8c02233e6..ecf2db5bb 100644 --- a/nvx-rs/src/host/config/tests.rs +++ b/nvx-rs/src/host/config/tests.rs @@ -695,6 +695,119 @@ fn cold_boot_command_lines_refuse_native_control_console_syntax() { } } +#[test] +fn native_argument_listener_names_are_distinct() { + let config = cold(blockless()); + let endpoint = r"\\.\pipe\nvx-console"; + for hypervisor in [Hypervisor::Kvm, Hypervisor::Mshv, Hypervisor::Whp] { + let runtime = Runtime { + hypervisor, + ..runtime() + }; + for console_endpoint in [endpoint, r"\\.\PIPE\NVX-CONSOLE", r"\\.\pipe\nvx-boot"] { + let rendered = config.openvmm_arguments( + &runtime, + endpoint, + Path::new("/missing/report"), + Some(console_endpoint), + ); + if console_endpoint == endpoint + || (hypervisor == Hypervisor::Whp + && console_endpoint.eq_ignore_ascii_case(endpoint)) + { + let error = rendered.unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!(error.to_string().contains("distinct endpoints")); + } else { + let arguments = rendered.unwrap(); + assert_eq!( + values(&arguments, "--virtio-console"), + [OsStr::new(&format!("listen={console_endpoint}"))] + ); + } + } + let disconnected = VmConfig { + console: ConsoleAttachment::Disconnected, + ..config.clone() + }; + let arguments = disconnected + .openvmm_arguments(&runtime, endpoint, Path::new("/missing/report"), None) + .unwrap(); + assert_eq!(values(&arguments, "--virtio-console"), [OsStr::new("none")]); + } +} + +#[cfg(any(unix, windows))] +#[test] +fn native_argument_paths_require_unicode() { + #[cfg(unix)] + let invalid = { + use std::os::unix::ffi::OsStringExt; + PathBuf::from(OsString::from_vec(vec![b'i', 0xff])) + }; + #[cfg(windows)] + let invalid = { + use std::os::windows::ffi::OsStringExt; + PathBuf::from(OsString::from_wide(&[105, 0xd800])) + }; + for scratch in [false, true] { + let mut root = erofs(); + if scratch { + root.upper = UpperConfig::Scratch { + path: invalid.clone(), + journal: Journal::Forbidden, + }; + } else { + root.image = invalid.clone(); + } + let config = cold(layered(root)); + config.check().unwrap(); + config.kernel_command_line().unwrap(); + for result in [ + config.check_command_line(), + config + .openvmm_arguments( + &runtime(), + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .map(|_| ()), + ] { + let error = result.unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!(error.to_string().contains("Unicode")); + } + } + + let image = PathBuf::from("image-\u{e9} with [nested[1]].erofs"); + let scratch = PathBuf::from("scratch-\u{f1} with spaces.ext4"); + let config = cold(layered(LayeredRoot { + image: image.clone(), + upper: UpperConfig::Scratch { + path: scratch.clone(), + journal: Journal::Forbidden, + }, + ..erofs() + })); + config.check_command_line().unwrap(); + let arguments = config + .openvmm_arguments( + &runtime(), + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .unwrap(); + assert_eq!( + values(&arguments, "--microvm-sandbox-block"), + [ + OsStr::new(&format!("distro:file:{},ro", image.display())), + OsStr::new(&format!("scratch:file:{}", scratch.display())), + ] + ); +} + #[test] fn cold_boot_arguments_select_the_configured_guest_without_managed_tokens() { for hypervisor in [Hypervisor::Kvm, Hypervisor::Mshv, Hypervisor::Whp] { From 7c4156ad5bde718d4aca90d41e1da546058858e6 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sat, 7 Nov 2026 10:58:08 -0800 Subject: [PATCH 28/29] host: refuse unrepresentable cold VM socket paths Reject Linux listener-option delimiters and socket names of 108 bytes or more before creating any cold-launch output. Cover both console configurations, Unicode byte boundaries, real valid socket binds, and preservation of caller inputs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 98b02bc4-2d86-490f-8241-2234ccd39feb --- nvx-rs/README.md | 5 +++ nvx-rs/src/host/vm.rs | 12 ++++++- nvx-rs/src/host/vm/tests.rs | 72 +++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 7a598001f..2c9d54dc0 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -489,6 +489,11 @@ 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 shorter +than 108 bytes and 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 diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index fbdbd0f66..9a8b63e20 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -255,7 +255,8 @@ impl Prepared { /// 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, or a complete Windows command line beyond the portable limit. +/// 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. @@ -276,6 +277,15 @@ pub fn create( 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 mut options = OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index b63e70956..9d803de9c 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -496,6 +496,78 @@ fn a_missing_boot_and_a_snapshot_are_refused_before_any_output_is_created() { } } +#[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"); + } + } +} + #[test] fn creation_rolls_back_its_log_and_never_removes_preexisting_outputs_or_inputs() { let mut names = vec![LOG, OUTCOME]; From ae25aa40807b3a5649659266906af24c0b428944 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sat, 7 Nov 2026 15:17:51 -0800 Subject: [PATCH 29/29] host: preserve external launch paths and exit status Resolve all three artifact paths against the caller before preparing the VM's working directory. Expose the existing status-bearing launch wait to host consumers and require actual VMM success alongside the guest report and teardown. Cover host-only timeout, zero/nonzero status and repeated waits with the existing public handoff fixture. Preserve non-Unix cfg compilation and document the reaped-child lifetime; regenerate both API snapshots. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4820e3a0-adc3-4382-8ccc-ae8b30d282d0 --- nvx-rs/examples/external-launch.rs | 17 +++++--- nvx-rs/public-api/linux.txt | 1 + nvx-rs/public-api/windows.txt | 1 + nvx-rs/src/host/platform/windows/spawn.rs | 10 ++--- nvx-rs/src/host/spawn.rs | 24 +++++++---- nvx-rs/tests/checked_handoff.rs | 49 +++++++++++++++++++++++ 6 files changed, 84 insertions(+), 18 deletions(-) diff --git a/nvx-rs/examples/external-launch.rs b/nvx-rs/examples/external-launch.rs index c74646477..3a10480c1 100644 --- a/nvx-rs/examples/external-launch.rs +++ b/nvx-rs/examples/external-launch.rs @@ -14,7 +14,7 @@ use nvx::host::config::{Boot, ConsoleAttachment, RootConfig, VmConfig}; use nvx::host::platform::Transport; use nvx::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; use nvx::host::supervisor::{self, Inherited}; -use nvx::host::{report, session, vm}; +use nvx::host::{artifacts, report, session, vm}; fn main() -> Result<(), Box> { let args: Vec<_> = std::env::args_os().collect(); @@ -43,9 +43,9 @@ fn main() -> Result<(), Box> { .map_err(Box::::from) }; let runtime = Runtime { - openvmm: args[2].clone().into(), - kernel: args[3].clone().into(), - initramfs: args[4].clone().into(), + openvmm: artifacts::absolute(Path::new(&args[2]))?, + kernel: artifacts::absolute(Path::new(&args[3]))?, + initramfs: artifacts::absolute(Path::new(&args[4]))?, identity: RuntimeIdentity::new(identity(6)?, identity(7)?, identity(8)?), hypervisor, cpu_profile: Some("auto".to_owned()), @@ -86,7 +86,7 @@ fn main() -> Result<(), Box> { breakaway_from_job, inherit: inherit.iter().map(Inherited::new).collect(), }; - let launched = supervisor::spawn(&command, capability.as_bytes(), log)?; + let mut launched = supervisor::spawn(&command, capability.as_bytes(), log)?; let result = (|| -> Result<(), Box> { let process = launched.process()?; let deadline = Instant::now() + Duration::from_secs(60); @@ -108,6 +108,13 @@ fn main() -> Result<(), Box> { if !process.wait(deadline)? { return Err(io::Error::new(io::ErrorKind::TimedOut, "VM did not exit").into()); } + match launched.wait_timeout(Duration::ZERO)? { + Some(0) => {} + Some(status) => { + return Err(io::Error::other(format!("VMM exited with status {status}")).into()); + } + None => return Err(io::Error::other("VMM exit status is unavailable").into()), + } let outcome = report::read(&outputs.outcome) .ok_or_else(|| io::Error::other("missing or invalid VM outcome"))?; if outcome.category.as_deref() != Some("success") diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index f6f7ee88f..94983430a 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -2709,6 +2709,7 @@ method nvx::host::supervisor::Launched::detach method nvx::host::supervisor::Launched::id method nvx::host::supervisor::Launched::kill_and_wait method nvx::host::supervisor::Launched::process +method nvx::host::supervisor::Launched::wait_timeout enum nvx::host::supervisor::Reattached variant nvx::host::supervisor::Reattached::Exited field nvx::host::supervisor::Reattached::Exited::record diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index ba40785ad..d348f1003 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -1631,6 +1631,7 @@ method nvx::host::supervisor::Launched::detach method nvx::host::supervisor::Launched::id method nvx::host::supervisor::Launched::kill_and_wait method nvx::host::supervisor::Launched::process +method nvx::host::supervisor::Launched::wait_timeout enum nvx::host::supervisor::Reattached variant nvx::host::supervisor::Reattached::Exited field nvx::host::supervisor::Reattached::Exited::record diff --git a/nvx-rs/src/host/platform/windows/spawn.rs b/nvx-rs/src/host/platform/windows/spawn.rs index 25c40aa3a..c06723ae1 100644 --- a/nvx-rs/src/host/platform/windows/spawn.rs +++ b/nvx-rs/src/host/platform/windows/spawn.rs @@ -11,26 +11,27 @@ use std::{mem, ptr}; #[cfg(feature = "cli")] use windows_sys::Win32::Foundation::{ - DUPLICATE_SAME_ACCESS, DuplicateHandle, INVALID_HANDLE_VALUE, WAIT_TIMEOUT, + DUPLICATE_SAME_ACCESS, DuplicateHandle, INVALID_HANDLE_VALUE, }; use windows_sys::Win32::Foundation::{ GetHandleInformation, HANDLE, HANDLE_FLAG_INHERIT, SetHandleInformation, WAIT_OBJECT_0, + WAIT_TIMEOUT, }; #[cfg(feature = "cli")] use windows_sys::Win32::System::Console::{ GetStdHandle, STD_ERROR_HANDLE, STD_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, }; +#[cfg(feature = "cli")] +use windows_sys::Win32::System::Threading::GetCurrentProcess; use windows_sys::Win32::System::Threading::{ CREATE_BREAKAWAY_FROM_JOB, CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, CreateProcessW, DeleteProcThreadAttributeList, - EXTENDED_STARTUPINFO_PRESENT, INFINITE, InitializeProcThreadAttributeList, + EXTENDED_STARTUPINFO_PRESENT, GetExitCodeProcess, INFINITE, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_JOB_LIST, PROCESS_CREATION_FLAGS, PROCESS_INFORMATION, STARTF_USESTDHANDLES, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute, WaitForSingleObject, }; -#[cfg(feature = "cli")] -use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetExitCodeProcess}; #[cfg(feature = "cli")] use super::process::refused_termination; @@ -78,7 +79,6 @@ impl LaunchedProcess { /// 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")] pub(crate) fn wait_timeout(&self, timeout: Duration) -> io::Result> { let milliseconds = u32::try_from(timeout.as_millis()).unwrap_or(INFINITE - 1); // SAFETY: CreateProcessW returned the handle with full access. diff --git a/nvx-rs/src/host/spawn.rs b/nvx-rs/src/host/spawn.rs index e6bd2f2d7..d1b9ebcc3 100644 --- a/nvx-rs/src/host/spawn.rs +++ b/nvx-rs/src/host/spawn.rs @@ -10,7 +10,6 @@ use std::path::PathBuf; use std::process::{Child, Command, Stdio}; #[cfg(not(windows))] use std::thread; -#[cfg(feature = "cli")] use std::time::Duration; #[cfg(not(windows))] use std::time::Instant; @@ -146,8 +145,9 @@ pub fn spawn( } /// A VMM that [`spawn`] started, which the caller has not released yet: on Unix, its child -/// process, which the caller has not reaped, so that its ID names no other process; on Windows, -/// its process handle. +/// process; on Windows, its process handle. On Unix the child's ID names no other process until +/// [`wait_timeout`](Self::wait_timeout) reaps it; hold it with [`process`](Self::process) before +/// waiting to keep a verified handle after it exits. /// /// Record it with [`Launch::record_process`](crate::host::store::Launch::record_process), or hold /// it with [`process`](Self::process). Then release it with [`detach`](Self::detach), or end it @@ -264,8 +264,10 @@ impl Launched { /// 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. - #[cfg(feature = "cli")] - pub(crate) fn wait_timeout(&mut self, timeout: Duration) -> io::Result> { + /// + /// On Unix a successful wait reaps the child; call [`process`](Self::process) before it to + /// retain a verified process handle. Later waits return the same status. + pub fn wait_timeout(&mut self, timeout: Duration) -> io::Result> { #[cfg(windows)] return Ok(self.process.wait_timeout(timeout)?.map(i64::from)); #[cfg(not(windows))] @@ -286,7 +288,7 @@ impl Launched { } /// Reaps the child if it has exited, and returns its status if it has. - #[cfg(all(feature = "cli", not(windows)))] + #[cfg(not(windows))] fn try_reap(&mut self) -> io::Result> { let status = self.child.try_wait()?; self.reaped |= status.is_some(); @@ -329,11 +331,17 @@ impl Launched { } /// Returns `Popen.returncode` for `status`: the exit code, or the negated signal number. -#[cfg(all(feature = "cli", not(windows)))] +#[cfg(not(windows))] fn status_value(status: std::process::ExitStatus) -> i64 { + #[cfg(unix)] use std::os::unix::process::ExitStatusExt; - match (status.code(), status.signal()) { + #[cfg(unix)] + let signal = status.signal(); + #[cfg(not(unix))] + let signal: Option = None; + + match (status.code(), signal) { (Some(code), _) => i64::from(code), (None, Some(signal)) => -i64::from(signal), (None, None) => 0, diff --git a/nvx-rs/tests/checked_handoff.rs b/nvx-rs/tests/checked_handoff.rs index b07a9e340..0875857f2 100644 --- a/nvx-rs/tests/checked_handoff.rs +++ b/nvx-rs/tests/checked_handoff.rs @@ -179,6 +179,55 @@ fn fixture_arguments(name: &str) -> Vec { .to_vec() } +/// A VMM fixture that exits with the requested status once the caller lets it go. +#[test] +#[ignore = "fixture that the exit-status test runs as a VMM"] +fn exit_status_fixture() { + let directory = env::current_dir().unwrap(); + if !directory.join(VMM_MARKER).exists() { + return; + } + let mut capability = [0; CAPABILITY_LEN]; + io::stdin().read_exact(&mut capability).unwrap(); + assert_eq!(capability, CAPABILITY); + let deadline = Instant::now() + VMM_LIFETIME; + while !directory.join(GO_MARKER).exists() { + if directory.join(EXIT_MARKER).exists() || Instant::now() >= deadline { + return; + } + thread::sleep(Duration::from_millis(5)); + } + let status = fs::read_to_string(directory.join("exit-status")).unwrap(); + std::process::exit(status.parse().unwrap()); +} + +#[test] +fn launched_waits_return_and_retain_the_actual_exit_status() { + for status in [0, 17] { + let mut vmm = VmmDirectory::new(); + fs::write(vmm.path().join("exit-status"), status.to_string()).unwrap(); + let command = VmmCommand::new( + env::current_exe().unwrap(), + fixture_arguments("exit_status_fixture"), + vmm.path(), + ); + let log = File::create(vmm.path().join("vmm.log")).unwrap(); + vmm.launched = Some(supervisor::spawn(&command, &CAPABILITY, log).unwrap()); + assert_eq!( + vmm.launched + .as_mut() + .unwrap() + .wait_timeout(Duration::ZERO) + .unwrap(), + None + ); + File::create(vmm.path().join(GO_MARKER)).unwrap(); + let launched = vmm.launched.as_mut().unwrap(); + assert_eq!(launched.wait_timeout(TIMEOUT).unwrap(), Some(status)); + assert_eq!(launched.wait_timeout(Duration::ZERO).unwrap(), Some(status)); + } +} + /// Returns the arguments that pass a VMM fixture the `--microvm-sandbox-block` values of /// `prepared`. fn block_arguments(prepared: &PreparedLaunch) -> Vec {