diff --git a/nvx-rs/README.md b/nvx-rs/README.md index a921381e1..020bf75cf 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -523,20 +523,40 @@ 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. Last, it checks a boot's capture with -`CaptureConfig::check`, below, and fails with its error. 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; a boot's capture that `CaptureConfig::check` refuses; +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. A `Boot` may also hold a `CaptureConfig`, what a capture of the VM adds to the VM's configuration, which keeps the rest, such as the NIC, the console, and the diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 80488fae6..36e58a4fe 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -1747,6 +1747,7 @@ field nvx::host::config::BlockGeometry::physical_block_size struct nvx::host::config::Boot field nvx::host::config::Boot::capture field nvx::host::config::Boot::command_line +const nvx::host::config::COMMAND_LINE_BUDGET struct nvx::host::config::Capacity field nvx::host::config::Capacity::boot field nvx::host::config::Capacity::capacity @@ -1804,6 +1805,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 @@ -3565,6 +3567,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 194be32ea..f5176e097 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -670,6 +670,7 @@ field nvx::host::config::BlockGeometry::physical_block_size struct nvx::host::config::Boot field nvx::host::config::Boot::capture field nvx::host::config::Boot::command_line +const nvx::host::config::COMMAND_LINE_BUDGET struct nvx::host::config::Capacity field nvx::host::config::Capacity::boot field nvx::host::config::Capacity::capacity @@ -727,6 +728,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 @@ -2487,6 +2489,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 46c33907c..e17df793c 100644 --- a/nvx-rs/src/host/config/mod.rs +++ b/nvx-rs/src/host/config/mod.rs @@ -19,9 +19,14 @@ use crate::activation::{self, Journal, MAX_HOSTNAME_BYTES, OverlayMode, View}; use crate::contract; 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"; @@ -139,6 +144,8 @@ 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, /// What a capture of the VM adds, if the VM may be captured. pub capture: Option, @@ -358,6 +365,17 @@ impl<'de> serde::Deserialize<'de> for CaptureIdentity { } 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. @@ -411,13 +429,15 @@ 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. Last, it checks a - /// boot's capture with [`CaptureConfig::check`], and fails with its error. + /// empty, longer than [`MAX_HOSTNAME_BYTES`], or holds a NUL; an export that + /// [`ExportPlan::check`] refuses for the root, whose error is the source; a boot's capture + /// that [`CaptureConfig::check`] refuses; 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")); @@ -445,6 +465,36 @@ impl VmConfig { if let Some(capture) = self.boot.as_ref().and_then(|boot| boot.capture.as_ref()) { capture.check()?; } + 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 a1f5781a8..d2ea319e0 100644 --- a/nvx-rs/src/host/config/tests.rs +++ b/nvx-rs/src/host/config/tests.rs @@ -928,3 +928,175 @@ 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(), + capture: None, + }), + ..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(), + capture: None, + }), + ..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(), + capture: None, + }), + ..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 2cd69749a..8dd6da639 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; +pub 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 29c21e573..65df05c17 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 restore_ready;