From d9cd276aa7c02eeb5c9536ceeeff60e1cf1f8463 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 04:27:02 -0800 Subject: [PATCH] host: check snapshot restore compatibility Check requested VM geometry, captured attachments, runtime identity and restore-time eligibility against retained snapshot metadata. Expose the checked NIC address without freezing the restore network policy or reopening snapshot entries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6789d491-7d13-4929-bc64-4edfec93ba60 --- nvx-rs/README.md | 30 +- nvx-rs/public-api/linux.txt | 2 + nvx-rs/public-api/windows.txt | 2 + nvx-rs/src/host/snapshot.rs | 213 +++++++++- nvx-rs/src/host/snapshot/key.rs | 15 +- nvx-rs/src/host/snapshot/manifest.rs | 13 +- nvx-rs/src/host/snapshot/tests.rs | 572 +++++++++++++++++++++++++++ 7 files changed, 831 insertions(+), 16 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 8c0198d71..92752f88f 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -125,7 +125,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) | A private VM directory, cold preparation, verified detached launch, confirmed stop, and caller-saved reattachment | -| [`nvx::host::snapshot`](#nvxhostsnapshot) | Strict private snapshot directories, verified keys and manifests, time eligibility, and the input of a future VM restore | +| [`nvx::host::snapshot`](#nvxhostsnapshot) | Strict private snapshot directories, verified metadata, VM compatibility, captured NIC addresses, and time eligibility | | [`nvx::host::config`](#nvxhostconfig) | `VmConfig` and `CaptureConfig`: pure configuration, cold-boot kernel/OpenVMM arguments, portable length checks, and capture identity | | [`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 | @@ -586,6 +586,34 @@ manifest's, and the canonical key is consistent with that manifest. `KEY_FILE`, `DESCRIPTION_FILE` and `OPENVMM_DIR` name the root entries. The description must exist, but its contents are never read, parsed or trusted. `Snapshot::key` and `manifest` expose the verified metadata. +Opening also checks that a captured NIC address fits `NicConfig`, refusing +its network, gateway and broadcast addresses with `Io`, and caches that +checked value for the infallible `nic` accessor. + +`Snapshot::accepts(&config, &runtime, &now)` checks a layered restore against +the captured boot resources and capacity. Processor counts are 1, 2, 4 or 8; +an encoded boot count of zero means all capacity processors are already +online. Memory is between boot and capacity, in multiples of 128 MiB, with +checked conversion from MiB to bytes. The image fits the placeholder and +keeps its logical and physical block sizes; a scratch upper matches the +dormant scratch's complete geometry. These checks read the requested disks' +real metadata, not the snapshot entries again. + +NIC presence must match, and an explicitly named address equals the captured +one; an unnamed address inherits it. `Snapshot::nic` returns only the captured +presence and address as an `Option`, never a network policy. +Each restore supplies its own policy. The boot console retains its captured +attachment policy: `recreate-listener` for a listener, or +`discard-while-disconnected` for a disconnected console. A mismatched, +missing or unknown policy is refused. These facts come from the same +validated manifest parse; the control listener and console device do not +prove that attachment. The runtime uses the same backend and captured CPU +profile, and has the same runtime identity. An omitted profile or `auto` +names the snapshot's profile; `host` also names a captured host-derived +profile. Requested disks must be regular files; on Linux, the geometry +check refuses a FIFO without waiting for its writer. +Compatibility refusals are `Unsupported`; an ineligible time contract at +the supplied `HostTime` is `Unavailable`. The caller keeps the directory and every entry private and immutable while using the snapshot; opening checks them once and retains no file handles diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index e52582295..58a3ffd2d 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -3087,8 +3087,10 @@ const nvx::host::snapshot::MAX_DOWNTIME_NS const nvx::host::snapshot::MAX_MANIFEST_LEN const nvx::host::snapshot::OPENVMM_DIR struct nvx::host::snapshot::Snapshot +method nvx::host::snapshot::Snapshot::accepts method nvx::host::snapshot::Snapshot::key method nvx::host::snapshot::Snapshot::manifest +method nvx::host::snapshot::Snapshot::nic method nvx::host::snapshot::Snapshot::open struct nvx::host::snapshot::SnapshotKey const nvx::host::snapshot::SnapshotKey::MAX_LEN diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index fcb344468..fd4fbb27f 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -1673,8 +1673,10 @@ const nvx::host::snapshot::MAX_DOWNTIME_NS const nvx::host::snapshot::MAX_MANIFEST_LEN const nvx::host::snapshot::OPENVMM_DIR struct nvx::host::snapshot::Snapshot +method nvx::host::snapshot::Snapshot::accepts method nvx::host::snapshot::Snapshot::key method nvx::host::snapshot::Snapshot::manifest +method nvx::host::snapshot::Snapshot::nic method nvx::host::snapshot::Snapshot::open struct nvx::host::snapshot::SnapshotKey const nvx::host::snapshot::SnapshotKey::MAX_LEN diff --git a/nvx-rs/src/host/snapshot.rs b/nvx-rs/src/host/snapshot.rs index 7fcb88f6e..f51ecbab2 100644 --- a/nvx-rs/src/host/snapshot.rs +++ b/nvx-rs/src/host/snapshot.rs @@ -7,6 +7,7 @@ //! [`SnapshotKey`] derives a compatibility key from a capture's class and manifest. //! [`SnapshotManifest::read`] reads the manifest that OpenVMM wrote, and //! [`TimeContract::eligibility`] checks its restore window at a [`HostTime`]. +//! [`Snapshot::accepts`] checks a requested VM against that verified metadata. //! //! # Canonical encoding //! @@ -40,9 +41,14 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; +use crate::host::config::{ + ConsoleAttachment, NicConfig, PROCESSOR_COUNTS, RootConfig, UpperConfig, VmConfig, +}; use crate::host::paths::{absolute, normalized}; +use crate::host::runtime::Runtime; use crate::host::store::regular; -use crate::host::{filesystem, platform}; +use crate::host::{filesystem, platform, sandbox, seal}; +use crate::net::Ipv4Cidr; use crate::{Error, Result}; pub use self::eligibility::{Downtime, DowntimeSource, Eligibility, HostTime, MAX_DOWNTIME_NS}; @@ -74,6 +80,8 @@ pub struct Snapshot { key: SnapshotKey, /// The strictly parsed metadata of OpenVMM's snapshot. manifest: SnapshotManifest, + /// The captured NIC's checked address, without a network policy. + nic: Option, } impl Snapshot { @@ -91,8 +99,8 @@ impl Snapshot { /// the returned snapshot. This check retains no open file handles or lock. /// /// Fails with [`ErrorKind::Io`](crate::ErrorKind::Io) for an unreadable or invalid directory, - /// entry, length or key relationship, and otherwise as [`SnapshotManifest::read`] and - /// [`SnapshotKey::from_bytes`] do. + /// entry, length, key relationship or captured NIC address, and otherwise as + /// [`SnapshotManifest::read`] and [`SnapshotKey::from_bytes`] do. pub fn open(dir: &Path) -> Result { let dir = absolute(&normalized(dir)).map_err(|error| entry_error(dir, error))?; check_directory(&dir, &[KEY_FILE, DESCRIPTION_FILE, OPENVMM_DIR])?; @@ -128,7 +136,26 @@ impl Snapshot { .map_err(|error| entry_error(&path, error))?; let key = SnapshotKey::from_bytes(&bytes)?; key.check_manifest(&manifest)?; - Ok(Self { dir, key, manifest }) + let nic = manifest + .machine + .nic + .as_ref() + .map(|nic| { + Ipv4Cidr::new(nic.ipv4, nic.prefix_length) + .map(|guest_network| NicConfig { guest_network }) + .map_err(|problem| { + Error::io(format!( + "the snapshot's captured NIC address is invalid: {problem}" + )) + }) + }) + .transpose()?; + Ok(Self { + dir, + key, + manifest, + nic, + }) } /// Returns the verified snapshot key. @@ -140,6 +167,184 @@ impl Snapshot { pub fn manifest(&self) -> &SnapshotManifest { &self.manifest } + + /// Returns the captured NIC's presence and checked address, without a network policy. + /// + /// An unnamed restore address inherits this value. This accessor reads no snapshot entry. + pub fn nic(&self) -> Option { + self.nic + } + + /// Checks whether this snapshot can restore `config` under `runtime` at `now`. + /// + /// Requires a layered root, processors from the effective boot count to capacity in + /// counts of 1, 2, 4 or 8, and memory from boot to capacity in multiples of 128 MiB. + /// An encoded boot processor count of zero means the entire capacity is already online. + /// The image must fit the placeholder with the same logical and physical block sizes; + /// a scratch upper must have the dormant scratch's complete geometry. + /// + /// NIC presence must match. A named address must equal the captured address; an unnamed + /// one inherits it. Network policies are not compared. The boot console must keep its + /// captured attachment policy: `recreate-listener` for a listener, or + /// `discard-while-disconnected` for a disconnected console. Neither its device nor the + /// control console proves this attachment. The runtime must use the captured backend and + /// CPU profile, and have the same runtime identity. An omitted profile or `auto` names + /// the captured profile; `host` also names it when that profile is host-derived. + /// + /// Reads only the requested image and scratch metadata, never the snapshot entries again. + /// The caller keeps the snapshot private and immutable, as [`open`](Self::open) requires. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a compatibility + /// refusal, with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) for an ineligible + /// time contract, and with [`ErrorKind::Io`](crate::ErrorKind::Io) for disk I/O failures. + pub fn accepts(&self, config: &VmConfig, runtime: &Runtime, now: &HostTime) -> Result<()> { + config.check()?; + let RootConfig::Layered(root) = &config.root else { + return Err(Error::unsupported( + "a snapshot restore requires a layered root", + )); + }; + let capacity = self.key.processors(); + let boot = match self.key.boot_processors() { + 0 => capacity, + boot => boot, + }; + if !PROCESSOR_COUNTS.contains(&config.processors) + || config.processors < boot + || config.processors > capacity + { + return Err(Error::unsupported(format!( + "snapshot processors must be 1, 2, 4 or 8, from boot {boot} to capacity \ + {capacity}, not {}", + config.processors + ))); + } + let memory = config.memory_mib.checked_mul(1024 * 1024).ok_or_else(|| { + Error::unsupported("snapshot memory in MiB overflows its byte length") + })?; + if memory < self.key.memory() || memory > self.key.memory_capacity() { + return Err(Error::unsupported(format!( + "snapshot memory must be from boot {} to capacity {} bytes, not {memory}", + self.key.memory(), + self.key.memory_capacity() + ))); + } + if !config.memory_mib.is_multiple_of(128) { + return Err(Error::unsupported( + "snapshot memory must be aligned to 128 MiB", + )); + } + let image = restore_geometry(&root.image, "image")?; + let placeholder = self.key.distro(); + if image.length > placeholder.length { + return Err(Error::unsupported(format!( + "snapshot image length {} exceeds the placeholder's {} bytes", + image.length, placeholder.length + ))); + } + if image.logical_block_size != placeholder.logical_block_size + || image.physical_block_size != placeholder.physical_block_size + { + return Err(Error::unsupported(format!( + "snapshot image block sizes {image:?} differ from the placeholder's \ + {placeholder:?}" + ))); + } + if let UpperConfig::Scratch { path, .. } = &root.upper { + let scratch = restore_geometry(path, "scratch")?; + if scratch != self.key.scratch() { + return Err(Error::unsupported(format!( + "snapshot scratch geometry {scratch:?} differs from the dormant scratch's {:?}", + self.key.scratch() + ))); + } + } + match (&config.network, self.nic()) { + (None, Some(_)) | (Some(_), None) => { + return Err(Error::unsupported( + "snapshot NIC presence differs from the requested VM's", + )); + } + (Some(network), Some(captured)) => { + if network + .guest_network + .is_some_and(|address| address != captured.guest_network) + { + return Err(Error::unsupported(format!( + "snapshot NIC address must remain {}", + captured.guest_network + ))); + } + } + (None, None) => {} + } + let console = self + .manifest + .attachments + .iter() + .find(|attachment| attachment.stable_id == "console:microvm-virtio0"); + let policy = match config.console { + ConsoleAttachment::Listener => "recreate-listener", + ConsoleAttachment::Disconnected => "discard-while-disconnected", + }; + if console.is_none_or(|attachment| attachment.reconnect_policy != policy) { + return Err(Error::unsupported( + "snapshot boot console attachment policy does not match the requested console", + )); + } + if runtime.hypervisor != self.key.hypervisor() { + return Err(Error::unsupported( + "snapshot backend differs from the runtime's hypervisor", + )); + } + let profile = self.key.cpu_profile(); + if !key::cpu_profile_matches( + &self.manifest.machine.cpu, + runtime.cpu_profile.as_deref().unwrap_or("auto"), + ) { + return Err(Error::unsupported(format!( + "snapshot CPU profile must remain {profile}" + ))); + } + if &runtime.identity != self.key.runtime() { + return Err(Error::unsupported(format!( + "snapshot runtime identity differs in {:?}", + self.key.runtime().differences(&runtime.identity) + ))); + } + let eligibility = self.manifest.time().eligibility(now); + if !eligibility.is_eligible() { + return Err(Error::unavailable(format!( + "snapshot time contract is {eligibility}" + ))); + } + Ok(()) + } +} + +/// Reads a requested regular disk's geometry without waiting for a Linux FIFO writer. +fn restore_geometry(path: &Path, description: &str) -> Result { + let file = seal::open_read(path).map_err(|error| { + Error::io(format!( + "cannot open snapshot restore {description} {}", + path.display() + )) + .with_source(error) + })?; + let metadata = file.metadata().map_err(|error| { + Error::io(format!( + "cannot examine snapshot restore {description} {}", + path.display() + )) + .with_source(error) + })?; + if !metadata.is_file() { + return Err(Error::unsupported(format!( + "the snapshot restore {description} {} is not a regular file", + path.display() + ))); + } + sandbox::block_geometry(&file, path, description) } /// Names the failing snapshot entry while retaining its I/O error. diff --git a/nvx-rs/src/host/snapshot/key.rs b/nvx-rs/src/host/snapshot/key.rs index 8ab84cc1d..e0c29175c 100644 --- a/nvx-rs/src/host/snapshot/key.rs +++ b/nvx-rs/src/host/snapshot/key.rs @@ -281,6 +281,15 @@ fn cpu_profile_problem(profile: &str) -> Option { }) } +/// Returns whether a CPU-profile request names the captured effective profile. +pub(super) fn cpu_profile_matches(profile: &CpuProfile, requested: &str) -> bool { + match requested { + "auto" => true, + "host" => profile.generation == HOST_GENERATION, + requested => profile.id() == requested, + } +} + /// Returns the guest's network on `nic`, its IPv4 address and prefix length, in the canonical form /// of a [`SandboxNetwork`]. fn guest_network_of(nic: &Nic) -> String { @@ -548,11 +557,7 @@ impl SnapshotKey { let profile = machine.cpu.id(); // `auto` selects the built-in profile of the host's CPU, or falls back to a profile that // the host derives, whose generation is `host`, when no built-in profile serves it. - let selected = match class.cpu_profile.as_str() { - "auto" => true, - "host" => machine.cpu.generation == HOST_GENERATION, - requested => profile == requested, - }; + let selected = cpu_profile_matches(&machine.cpu, &class.cpu_profile); if !selected { return mismatch( format!("its CPU profile is {profile}"), diff --git a/nvx-rs/src/host/snapshot/manifest.rs b/nvx-rs/src/host/snapshot/manifest.rs index d21fe338f..d6f45bafc 100644 --- a/nvx-rs/src/host/snapshot/manifest.rs +++ b/nvx-rs/src/host/snapshot/manifest.rs @@ -648,6 +648,8 @@ pub struct SnapshotManifest { time: TimeContract, /// The machine. pub(super) machine: Machine, + /// The captured host attachments, retained by the same strict manifest parse. + pub(super) attachments: Vec, } pub(in crate::host) use SnapshotManifest as TemplateManifest; @@ -766,9 +768,10 @@ impl SnapshotManifest { "has the invalid creation time of {seconds} seconds and {nanos} nanoseconds" )) })?; - let Some(contract) = raw.contract else { + let Some(mut contract) = raw.contract else { return Err(invalid("has no machine contract")); }; + let attachments = std::mem::take(&mut contract.attachments); let (machine, time) = check_contract(contract, raw.vp_count, raw.memory_size)?; Ok(Self { created_at, @@ -776,16 +779,14 @@ impl SnapshotManifest { state_len: raw.state_size, time, machine, + attachments, }) } } /// A host attachment of a snapshot's machine contract, `openvmm.snapshot.SnapshotAttachment`: an -/// endpoint outside the VM, such as the backend of a console, which a restore reconnects, as far -/// as `nvx-rs run` reads it to restore the snapshot. -// A template's manifest checks the form of its attachments alone, so only the CLI reads what this -// holds. -#[cfg_attr(not(feature = "cli"), allow(dead_code))] +/// endpoint outside the VM, such as the backend of a console, whose identity and reconnect +/// policy snapshot compatibility and `nvx-rs run` read for a restore. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct Attachment { /// The stable ID of the device that it attaches, such as `console:microvm-virtio0`. diff --git a/nvx-rs/src/host/snapshot/tests.rs b/nvx-rs/src/host/snapshot/tests.rs index c73fe2710..7fb45bb31 100644 --- a/nvx-rs/src/host/snapshot/tests.rs +++ b/nvx-rs/src/host/snapshot/tests.rs @@ -14,10 +14,18 @@ use super::{ OPENVMM_DIR, Snapshot, SnapshotKey, SnapshotManifest, }; use crate::ErrorKind; +use crate::activation::{Journal, MachineIdMode, OverlayMode, View}; +use crate::host::config::{ + ConsoleAttachment, LayeredRoot, NetworkConfig, NicConfig, RootConfig, UpperConfig, VmConfig, +}; use crate::host::network::{Access, NetworkPolicy}; +use crate::host::process::BootId; +use crate::host::runtime::{LaunchOptions, Runtime}; use crate::host::sandbox::SandboxNetwork; use crate::host::store::{ArtifactIdentity, RuntimeIdentity}; use crate::hypervisor::Hypervisor; +use crate::net::Ipv4Cidr; +use crate::protocol::root::RootImage; /// Bytes in a MiB. pub(in crate::host) const MIB: u64 = 1024 * 1024; @@ -1392,3 +1400,567 @@ fn snapshot_description_needs_no_data_read_access() { ); assert_eq!(Snapshot::open(&fixture.dir).unwrap().key(), &fixture.key); } + +struct RestoreFixture { + snapshot: SnapshotFixture, + config: VmConfig, + runtime: Runtime, + now: super::HostTime, +} + +impl RestoreFixture { + fn new(nic: bool) -> Self { + let mut snapshot = SnapshotFixture::new(); + snapshot.spec = if nic { + let mut spec = ManifestSpec::with_network(); + spec.network().ipv4 = [10, 42, 0, 2]; + spec.network().gateway = [10, 42, 0, 1]; + spec + } else { + ManifestSpec::template() + }; + snapshot.spec.vp_count = 4; + snapshot.spec.contract().topology = (1, 1, 4, 1, vec![0, 1, 2, 3]); + snapshot.spec.contract().boot_online_vp_count = 2; + let image = snapshot.root.path().join("image.raw"); + let scratch = snapshot.root.path().join("scratch.raw"); + for (index, path, length) in [(0, &image, MIB), (1, &scratch, 2 * MIB)] { + let file = crate::host::store::files::create_new(path).unwrap(); + crate::host::platform::make_sparse(&file).unwrap(); + file.set_len(length).unwrap(); + let geometry = crate::host::sandbox::block_geometry(&file, path, "fixture").unwrap(); + let block = &mut snapshot.spec.contract().blocks[index]; + block.length = if index == 0 { 4 * MIB } else { length }; + block.logical_block_size = geometry.logical_block_size; + block.physical_block_size = geometry.physical_block_size; + } + let config = VmConfig { + processors: 2, + memory_mib: 512, + root: RootConfig::Layered(LayeredRoot { + image, + format: RootImage::erofs(None), + gpt_first_partition_bound: None, + upper: UpperConfig::Scratch { + path: scratch, + journal: Journal::Forbidden, + }, + overlay: OverlayMode::TrustedLowers, + }), + view: View { + hostname: b"restored".to_vec(), + machine_id: MachineIdMode::Generated, + }, + network: nic.then(|| NetworkConfig { + guest_network: None, + policy: NetworkPolicy::new(Access::Deny), + }), + filesystem: None, + console: ConsoleAttachment::Disconnected, + boot: None, + }; + let runtime = Runtime { + openvmm: snapshot.root.path().join("unread-openvmm"), + kernel: snapshot.root.path().join("unread-kernel"), + initramfs: snapshot.root.path().join("unread-initramfs"), + identity: class().runtime, + hypervisor: Hypervisor::Kvm, + cpu_profile: Some("intel.icelake-sp.v1".to_owned()), + windows_breakaway: false, + options: LaunchOptions::default(), + }; + let now = super::HostTime { + host_id: [1; 16], + boot_id: BootId::from_bytes([2; 16]), + clock: super::HostClock::LinuxBoottime, + monotonic_ns: 123_000_001_000, + utc_ns: 1_700_000_000_000_001_000, + }; + let mut fixture = Self { + snapshot, + config, + runtime, + now, + }; + fixture.write_metadata(); + fixture + } + + fn write_metadata(&mut self) { + let manifest = SnapshotManifest::parse(&self.snapshot.spec.encode()).unwrap(); + let mut class = class(); + class.processors = self.snapshot.spec.vp_count; + class.boot_processors = match manifest.machine.boot_processors { + 0 => class.processors, + count => count, + }; + class.distro = manifest.machine.distro; + class.scratch = manifest.machine.scratch; + class.network = self + .snapshot + .spec + .contract + .as_ref() + .unwrap() + .network + .as_ref() + .map(|network| { + SandboxNetwork::new( + &format!( + "{}/{}", + std::net::Ipv4Addr::from(network.ipv4), + network.prefix_length + ), + NetworkPolicy::new(Access::Allow), + ) + .unwrap() + }); + self.snapshot.key = SnapshotKey::new(&class, &manifest).unwrap(); + crate::host::store::files::write_private( + &self.snapshot.dir.join(KEY_FILE), + &self.snapshot.key.to_bytes(), + ) + .unwrap(); + crate::host::store::files::write_private( + &self.snapshot.dir.join(OPENVMM_DIR).join(MANIFEST_NAME), + &self.snapshot.spec.encode(), + ) + .unwrap(); + } + + fn opened(&self) -> Snapshot { + Snapshot::open(&self.snapshot.dir).unwrap() + } + + fn refused(&self, field: &str) { + let error = self + .opened() + .accepts(&self.config, &self.runtime, &self.now) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{field}: {error}"); + assert!(error.message().contains(field), "{field}: {error}"); + } +} + +#[test] +fn snapshot_accepts_matching_real_disk_geometry_and_every_resource_boundary() { + for nic in [false, true] { + let mut fixture = RestoreFixture::new(nic); + let snapshot = fixture.opened(); + for processors in [2, 4] { + for memory_mib in (512..=2048).step_by(128) { + fixture.config.processors = processors; + fixture.config.memory_mib = memory_mib; + snapshot + .accepts(&fixture.config, &fixture.runtime, &fixture.now) + .unwrap(); + } + } + let RootConfig::Layered(root) = &mut fixture.config.root else { + unreachable!() + }; + fs::OpenOptions::new() + .write(true) + .open(&root.image) + .unwrap() + .set_len(4 * MIB) + .unwrap(); + root.upper = UpperConfig::Ram { + max_memory_percent: 50, + }; + snapshot + .accepts(&fixture.config, &fixture.runtime, &fixture.now) + .unwrap(); + } +} + +#[test] +fn snapshot_accepts_refuses_a_blockless_root_and_each_processor_mismatch() { + let mut fixture = RestoreFixture::new(false); + fixture.config.root = RootConfig::Blockless; + fixture.refused("layered root"); + for processors in [1, 8, 3] { + let mut fixture = RestoreFixture::new(false); + fixture.config.processors = processors; + fixture.refused("processors"); + } +} + +#[test] +fn snapshot_accepts_treats_zero_boot_processors_as_all_capacity_online() { + let mut fixture = RestoreFixture::new(false); + fixture.snapshot.spec.contract().boot_online_vp_count = 0; + fixture.write_metadata(); + assert_eq!(fixture.opened().key().boot_processors(), 0); + fixture.refused("processors"); + fixture.config.processors = 4; + fixture + .opened() + .accepts(&fixture.config, &fixture.runtime, &fixture.now) + .unwrap(); +} + +#[test] +fn snapshot_accepts_refuses_each_memory_mismatch_without_rounding_or_overflow() { + for memory_mib in [384, 2176, 513] { + let mut fixture = RestoreFixture::new(false); + fixture.config.memory_mib = memory_mib; + fixture.refused("memory"); + } + for memory_mib in [u64::MAX, (1 << 44) + 512] { + let mut fixture = RestoreFixture::new(false); + fixture.config.memory_mib = memory_mib; + fixture.refused("overflows"); + } +} + +#[test] +fn snapshot_accepts_reports_missing_image_and_scratch_files_as_io() { + for scratch in [false, true] { + let fixture = RestoreFixture::new(false); + let snapshot = fixture.opened(); + let RootConfig::Layered(root) = &fixture.config.root else { + unreachable!() + }; + let (path, description) = if scratch { + let UpperConfig::Scratch { path, .. } = &root.upper else { + unreachable!() + }; + (path, "scratch") + } else { + (&root.image, "image") + }; + fs::remove_file(path).unwrap(); + let error = snapshot + .accepts(&fixture.config, &fixture.runtime, &fixture.now) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io, "{description}: {error}"); + assert!( + error.message().contains(description), + "{description}: {error}" + ); + } +} + +#[test] +fn snapshot_accepts_refuses_an_image_longer_than_its_placeholder() { + let fixture = RestoreFixture::new(false); + let RootConfig::Layered(root) = &fixture.config.root else { + unreachable!() + }; + fs::OpenOptions::new() + .write(true) + .open(&root.image) + .unwrap() + .set_len(4 * MIB + 512) + .unwrap(); + fixture.refused("image length"); +} + +#[test] +fn snapshot_accepts_refuses_each_image_block_size_mismatch() { + for logical in [true, false] { + let mut fixture = RestoreFixture::new(false); + let block = &mut fixture.snapshot.spec.contract().blocks[0]; + if logical { + block.logical_block_size *= 2; + } else { + block.physical_block_size *= 2; + } + fixture.write_metadata(); + fixture.refused("image block sizes"); + } +} + +#[test] +fn snapshot_accepts_refuses_each_scratch_geometry_mismatch() { + for field in 0..3 { + let mut fixture = RestoreFixture::new(false); + let block = &mut fixture.snapshot.spec.contract().blocks[1]; + match field { + 0 => block.length *= 2, + 1 => block.logical_block_size *= 2, + 2 => block.physical_block_size *= 2, + _ => unreachable!(), + } + fixture.write_metadata(); + fixture.refused("scratch geometry"); + } +} + +#[test] +fn snapshot_accepts_refuses_both_nic_presence_mismatches_and_each_named_address_mismatch() { + for captured in [false, true] { + let mut fixture = RestoreFixture::new(captured); + fixture.config.network = (!captured).then(|| NetworkConfig { + guest_network: None, + policy: NetworkPolicy::new(Access::Deny), + }); + fixture.refused("NIC presence"); + } + for address in ["10.42.0.3/24", "10.42.0.2/25"] { + let mut fixture = RestoreFixture::new(true); + fixture.config.network.as_mut().unwrap().guest_network = + Some(Ipv4Cidr::parse(address).unwrap()); + fixture.refused("NIC address"); + } +} + +#[test] +fn snapshot_accepts_inherits_the_captured_address_and_does_not_compare_policies() { + let mut fixture = RestoreFixture::new(true); + let snapshot = fixture.opened(); + for address in [None, Some(Ipv4Cidr::parse("10.42.0.2/24").unwrap())] { + for access in [Access::Deny, Access::Allow] { + fixture.config.network = Some(NetworkConfig { + guest_network: address, + policy: NetworkPolicy::new(access), + }); + snapshot + .accepts(&fixture.config, &fixture.runtime, &fixture.now) + .unwrap(); + } + } +} + +#[test] +fn snapshot_nic_returns_only_the_captured_presence_and_address_without_rereading() { + for captured in [false, true] { + let fixture = RestoreFixture::new(captured); + let snapshot = fixture.opened(); + let expected = captured.then(|| NicConfig { + guest_network: Ipv4Cidr::parse("10.42.0.2/24").unwrap(), + }); + assert_eq!(snapshot.nic(), expected); + fs::rename( + &fixture.snapshot.dir, + fixture.snapshot.root.path().join("saved"), + ) + .unwrap(); + assert_eq!(snapshot.nic(), expected); + } +} + +#[test] +fn snapshot_open_refuses_captured_network_gateway_and_broadcast_addresses_as_io() { + use crate::host::store::files::write_private; + + for address in [[10, 42, 0, 0], [10, 42, 0, 1], [10, 42, 0, 255]] { + let mut fixture = RestoreFixture::new(true); + let mut bytes = fixture.snapshot.key.to_bytes(); + let positions: Vec<_> = bytes + .windows(4) + .enumerate() + .filter_map(|(index, bytes)| (bytes == [10, 42, 0, 2]).then_some(index)) + .collect(); + assert_eq!( + positions.len(), + 1, + "the fixture has one encoded NIC address" + ); + bytes[positions[0]..positions[0] + 4].copy_from_slice(&address); + fixture.snapshot.spec.network().ipv4 = address; + let manifest = SnapshotManifest::parse(&fixture.snapshot.spec.encode()).unwrap(); + let key = SnapshotKey::from_bytes(&bytes).unwrap(); + key.check_manifest(&manifest).unwrap(); + write_private(&fixture.snapshot.dir.join(KEY_FILE), &bytes).unwrap(); + write_private( + &fixture.snapshot.dir.join(OPENVMM_DIR).join(MANIFEST_NAME), + &fixture.snapshot.spec.encode(), + ) + .unwrap(); + let error = Snapshot::open(&fixture.snapshot.dir).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io, "{error}"); + assert!(error.message().contains("captured NIC address"), "{error}"); + } +} + +#[test] +fn snapshot_accepts_requires_the_boot_console_attachment_not_the_control_listener_or_device() { + let mut fixture = RestoreFixture::new(false); + fixture.config.console = ConsoleAttachment::Listener; + fixture.refused("boot console"); + let console = &mut fixture.snapshot.spec.contract().attachments[0]; + console.reconnect_policy = "recreate-listener".to_owned(); + console.identity_kind = "unix-socket".to_owned(); + console.identity = b"/captured/boot-console".to_vec(); + fixture.write_metadata(); + let snapshot = fixture.opened(); + snapshot + .accepts(&fixture.config, &fixture.runtime, &fixture.now) + .unwrap(); + fixture.config.console = ConsoleAttachment::Disconnected; + fixture.refused("boot console"); +} + +#[test] +fn snapshot_accepts_refuses_missing_and_unknown_boot_console_policies() { + for missing in [false, true] { + let mut fixture = RestoreFixture::new(false); + if missing { + fixture + .snapshot + .spec + .contract() + .attachments + .retain(|attachment| attachment.stable_id != "console:microvm-virtio0"); + } else { + fixture.snapshot.spec.contract().attachments[0].reconnect_policy = "unknown".to_owned(); + } + fixture.write_metadata(); + for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { + fixture.config.console = console; + fixture.refused("boot console"); + } + } +} + +#[test] +fn snapshot_accepts_refuses_a_different_backend_and_each_cpu_profile_mismatch() { + let mut fixture = RestoreFixture::new(false); + fixture.runtime.hypervisor = Hypervisor::Mshv; + fixture.refused("backend"); + for profile in ["host", "amd.genoa.v2"] { + let mut fixture = RestoreFixture::new(false); + fixture.runtime.cpu_profile = Some(profile.to_owned()); + fixture.refused("CPU profile"); + } +} + +#[test] +fn snapshot_accepts_the_default_auto_and_matching_host_cpu_profile_requests() { + for captured in ["intel.icelake-sp.v1", "intel.host.v1"] { + let mut fixture = RestoreFixture::new(false); + fixture.snapshot.spec.cpu().id = captured.to_owned(); + fixture.write_metadata(); + let snapshot = fixture.opened(); + for requested in [None, Some("auto"), Some(captured)] { + fixture.runtime.cpu_profile = requested.map(str::to_owned); + snapshot + .accepts(&fixture.config, &fixture.runtime, &fixture.now) + .unwrap(); + } + if captured == "intel.host.v1" { + fixture.runtime.cpu_profile = Some("host".to_owned()); + snapshot + .accepts(&fixture.config, &fixture.runtime, &fixture.now) + .unwrap(); + } + } +} + +#[cfg(target_os = "linux")] +#[test] +fn snapshot_accepts_refuses_nonregular_disks_without_waiting_for_a_fifo_writer() { + use std::sync::mpsc; + use std::time::Duration; + + for scratch in [false, true] { + for fifo in [false, true] { + let fixture = RestoreFixture::new(false); + let snapshot = fixture.opened(); + let RootConfig::Layered(root) = &fixture.config.root else { + unreachable!() + }; + let path = if scratch { + let UpperConfig::Scratch { path, .. } = &root.upper else { + unreachable!() + }; + path + } else { + &root.image + }; + fs::remove_file(path).unwrap(); + if fifo { + nix::unistd::mkfifo(path, nix::sys::stat::Mode::S_IRUSR).unwrap(); + } else { + fs::create_dir(path).unwrap(); + } + let config = fixture.config.clone(); + let runtime = fixture.runtime.clone(); + let now = fixture.now; + let (send, receive) = mpsc::channel(); + let worker = std::thread::spawn(move || { + send.send(snapshot.accepts(&config, &runtime, &now)) + .unwrap(); + }); + let error = receive + .recv_timeout(Duration::from_secs(5)) + .expect("disk geometry must not wait for a FIFO writer") + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert!(error.message().contains("regular file"), "{error}"); + worker.join().unwrap(); + } + } +} + +#[test] +fn snapshot_accepts_refuses_every_runtime_identity_part_independently() { + let changes: [fn(&mut RuntimeIdentity); 6] = [ + |identity| identity.openvmm = artifact('d'), + |identity| identity.kernel = artifact('d'), + |identity| identity.initramfs = artifact('d'), + |identity| identity.boot_contract += 1, + |identity| identity.control_contract.push_str("-other"), + |identity| identity.control_session += 1, + ]; + for change in changes { + let mut fixture = RestoreFixture::new(false); + change(&mut fixture.runtime.identity); + fixture.refused("runtime identity"); + } +} + +#[test] +fn snapshot_accepts_reports_early_and_expired_time_as_unavailable() { + let fixture = RestoreFixture::new(false); + let snapshot = fixture.opened(); + let anchor = snapshot.manifest().time(); + for monotonic_ns in [ + anchor.capture_monotonic_ns - 1, + anchor.capture_monotonic_ns + super::MAX_DOWNTIME_NS, + ] { + let now = super::HostTime { + monotonic_ns, + ..fixture.now + }; + let error = snapshot + .accepts(&fixture.config, &fixture.runtime, &now) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable, "{error}"); + } + let now = super::HostTime { + monotonic_ns: anchor.capture_monotonic_ns + super::MAX_DOWNTIME_NS - 200, + ..fixture.now + }; + snapshot + .accepts(&fixture.config, &fixture.runtime, &now) + .unwrap(); + let now = super::HostTime { + host_id: [3; 16], + utc_ns: anchor.capture_utc_ns - 1, + ..fixture.now + }; + assert_eq!( + snapshot + .accepts(&fixture.config, &fixture.runtime, &now) + .unwrap_err() + .kind(), + ErrorKind::Unavailable + ); +} + +#[test] +fn snapshot_accepts_never_reopens_snapshot_paths_or_reads_runtime_artifacts() { + let fixture = RestoreFixture::new(true); + let snapshot = fixture.opened(); + fs::rename( + &fixture.snapshot.dir, + fixture.snapshot.root.path().join("saved"), + ) + .unwrap(); + snapshot + .accepts(&fixture.config, &fixture.runtime, &fixture.now) + .unwrap(); +}