Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions nvx-rs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -505,16 +521,22 @@ 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. 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
Expand Down
6 changes: 6 additions & 0 deletions nvx-rs/public-api/linux.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions nvx-rs/public-api/windows.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
54 changes: 51 additions & 3 deletions nvx-rs/src/host/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String> {
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.
///
Expand All @@ -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"));
Expand Down
188 changes: 188 additions & 0 deletions nvx-rs/src/host/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<CaptureConfig>| VmConfig {
Expand Down Expand Up @@ -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}");
}
Loading
Loading