diff --git a/nvx-rs/README.md b/nvx-rs/README.md index ce041f945..a921381e1 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -115,6 +115,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 | @@ -125,7 +126,7 @@ changes only when the module's purpose does. | [`nvx::host::runtime`](#nvxhostruntime) | `Runtime`: what every launch shares, the runtime's files and their identity, the hypervisor and its probe, and the launch options | | [`nvx::host::vm`](#nvxhostvm) | The directory of a VM, private to the current user, with the fixed names of the files and endpoints of its launches | | [`nvx::host::snapshot`](#nvxhostsnapshot) | Snapshot compatibility keys, strict manifests, and restore-time eligibility | -| [`nvx::host::config`](#nvxhostconfig) | `VmConfig`, a VM's configuration as one value, and `CaptureConfig`, what a capture adds and the identity of its snapshots, checked without reading the host | +| [`nvx::host::config`](#nvxhostconfig) | `VmConfig` and `CaptureConfig`: VM/capture configuration, cold-boot kernel tokens and snapshot identity, without host I/O | | [`nvx::host::restore`](#nvxhostrestore) | A caller-driven restore to Ready without I/O or a clock, and its peer-verified restore-ready listener | | [`nvx::host::artifacts`](#nvxhostartifacts) | Locating the OpenVMM executable and the guest artifacts that a VM runs, and their runtime identity | | [`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 | @@ -290,6 +291,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 @@ -505,6 +521,12 @@ 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 @@ -512,9 +534,9 @@ 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 -and the boot's command line are checked where they are lowered, with the -guest's address and NVX's own tokens. +`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. 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 7711b1fed..80488fae6 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -1803,6 +1803,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 @@ -3774,6 +3775,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 3c4e736cc..194be32ea 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -726,6 +726,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 @@ -2696,6 +2697,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 4a9427141..46c33907c 100644 --- a/nvx-rs/src/host/config/mod.rs +++ b/nvx-rs/src/host/config/mod.rs @@ -19,7 +19,8 @@ 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::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. @@ -357,6 +358,52 @@ impl<'de> serde::Deserialize<'de> for CaptureIdentity { } 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. /// @@ -368,8 +415,9 @@ impl VmConfig { /// [`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. /// - /// 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")); diff --git a/nvx-rs/src/host/config/tests.rs b/nvx-rs/src/host/config/tests.rs index bea61e2b4..a1f5781a8 100644 --- a/nvx-rs/src/host/config/tests.rs +++ b/nvx-rs/src/host/config/tests.rs @@ -7,10 +7,14 @@ use std::time::Duration; use super::*; 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::runtime::{ArtifactIdentity, GuestPowerAction, LaunchOptions, RuntimeIdentity}; +use crate::host::sandbox::{SandboxDistro, SandboxPlan, SandboxSettings, SandboxUpper}; use crate::hypervisor::Hypervisor; +use crate::protocol::{cmdline, parse_host_mapping_count}; use crate::uuid::Uuid; use crate::{ErrorCode, ErrorKind}; @@ -460,6 +464,152 @@ fn each_refusal_of_a_capture_names_what_it_refuses() { } } +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(), + capture: None, + }), + ..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 the_check_of_a_configuration_checks_its_boots_capture() { let booting = |capture: Option| VmConfig { @@ -740,3 +890,41 @@ fn what_each_launch_or_clone_sets_leaves_the_identity_alone() { capture().identity(&with_export("/mnt/b"), &runtime()) ); } + +#[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(), + capture: None, + }), + ..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(), + capture: None, + }), + ..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 da5de0c43..29c21e573 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 @@ -53,6 +54,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}"); + } +}