From d9cf939b1d52fe32b7d0b2542d846e9821673d35 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Thu, 5 Nov 2026 13:13:49 -0800 Subject: [PATCH] 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}" + ); + } +}