From d962560b1f551121ae6001215db6c1f60b1e8dbe Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sat, 24 Oct 2026 23:01:20 -0700 Subject: [PATCH 1/3] nvx-rs: reread a clone's GPT partition table and require a fresh upper A clone's activation refused a GPT image, since the kernel created the block devices of the disk's partitions when it found the template's placeholder, so they need not be the clone's. It now assembles one: - Before the assembly, nvx::guest::clone::apply asks the kernel to reread the distro disk's partition table with BLKRRPART, once the device appeared, through a new wrapper beside BLKFLSBUF in guest::sys::ioctl. The kernel drops the placeholder's partitions and creates the clone's, with their nodes, from the same read-only bytes that the assembly then reads and checks against the record's disk GUID and first-partition bound, as at a cold boot. A reread that fails, before anything is mounted, is ApplyError::Gpt, which now carries the root::Error, with the new DeviceStep::Reread when the kernel refuses it. - A clone's root must start as its image alone, so its assembly takes a crate-private option that refuses an upper whose `upper` or `work` directory exists and is not empty, with the new root::Error::NotFresh, once the upper is mounted and before the overlay. The check reads the directory's status and at most one entry, and no file's content. A cold sandbox's root::assemble keeps a scratch's upper across boots, as before. Both failures end the runtime adapter's clone with the existing activation status 54, whose documentation already covers them. The adapter's fake session and its README follow the new error. The tests cover the reread before the assembly, its errors, and the freshness check and its bound, without a VM, and an ignored test checks the reread against the kernel with a loop device, as root. The VM tests of a GPT clone come with the restore of clones in VMs. Part of #845 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80932e09-af82-4b79-b417-70a241303cda --- nvx-rs/README.md | 30 +- nvx-rs/src/guest/clone.rs | 27 +- nvx-rs/src/guest/clone/activation/apply.rs | 112 +++-- .../src/guest/clone/activation/apply/tests.rs | 217 +++++++-- nvx-rs/src/guest/root.rs | 127 +++++- nvx-rs/src/guest/root/block.rs | 36 +- nvx-rs/src/guest/root/kernel.rs | 49 +- nvx-rs/src/guest/root/tests.rs | 418 +++++++++++++++++- nvx-rs/src/guest/sys/ioctl.rs | 36 ++ nvx-rs/src/guest/sys/mod.rs | 3 +- tests/runtime-adapter/README.md | 37 +- tests/runtime-adapter/src/worker.rs | 11 +- .../runtime-adapter/src/worker/activation.rs | 18 +- tests/runtime-adapter/tests/guest_session.rs | 51 ++- 14 files changed, 1016 insertions(+), 156 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 0214c8868..14c839af9 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -1190,19 +1190,23 @@ Activate whose record the adapter's framing refused unread, as longer than a record can be, fails as a record that long does; the adapter applies the record with `apply`, as at a cold boot: it assembles the root as `root::assemble` does, with the record's image, an EROFS image whose UUID it checks if the record -expects one, its upper, a tmpfs or the scratch with the record's journal policy, -and its overlay mode, and with the `RootTemplate` that the guest's own -configuration holds, which its template captured: the runtime directory, the -block devices, and the timeout, but waits for no device past the activation's -deadline, which the adapter passes, so that the application ends by then. - -It refuses a GPT disk before any side effect, since the kernel created the block -devices of its partitions for the template's placeholder, and, with the record's -workload identity, checks its account in the assembled root before Ready, as -NVX's init checks it at boot: `entry::Identity::new(uid, gid)`, without -supplementary groups or capabilities, in existing-account mode, as which every -execution runs, while without one each execution names its own identity, which -`Entry::check` checks. It returns the `Applied` root and identity, or an +expects one, or a GPT disk whose GUID it checks if the record expects one, its +upper, a tmpfs or the scratch with the record's journal policy, and its overlay +mode, and with the `RootTemplate` that the guest's own configuration holds, +which its template captured: the runtime directory, the block devices, and the +timeout, but waits for no device past the activation's deadline, which the +adapter passes, so that the application ends by then. + +For a GPT disk, it first asks the kernel to reread the disk's partition table +with `BLKRRPART`, since the kernel created the block devices of partitions for +the template's placeholder. Whatever the image, it requires a fresh upper, whose +`upper` and `work` directories are missing or empty, in a bounded check that +reads no file, while a cold sandbox's scratch keeps its upper across boots. With +the record's workload identity, it checks its account in the assembled root +before Ready, as NVX's init checks it at boot: `entry::Identity::new(uid, gid)`, +without supplementary groups or capabilities, in existing-account mode, as which +every execution runs, while without one each execution names its own identity, +which `Entry::check` checks. It returns the `Applied` root and identity, or an `ApplyError`, after which nothing that it mounted stays mounted unless the error reports the unmounts that failed, and applies none of the record's export, network, hostname, or machine ID. diff --git a/nvx-rs/src/guest/clone.rs b/nvx-rs/src/guest/clone.rs index 4062548b9..51f5f0b36 100644 --- a/nvx-rs/src/guest/clone.rs +++ b/nvx-rs/src/guest/clone.rs @@ -144,14 +144,20 @@ //! //! [`apply`](fn@apply) activates the clone as at a cold boot. It assembles the root as //! [`root::assemble`] does, with the record's image, an EROFS image whose UUID it checks if the -//! record expects one, its upper, a tmpfs or the scratch, with the record's journal policy, and -//! its overlay mode, and with the inputs that the guest takes from its own configuration, which -//! its template captured, a [`RootTemplate`]: the runtime directory, the block devices, and how -//! long the assembly waits for each, but never past the activation's deadline, which the adapter -//! passes, so that the application ends by then. It refuses a GPT disk before anything has a -//! side effect: the kernel created the block devices of a disk's partitions when it found the -//! template's placeholder. If the record has a workload identity, it then checks its account in -//! the assembled root, before Ready, as NVX's init checks it at boot: the identity is +//! record expects one, or a GPT disk whose GUID it checks if the record expects one, its upper, a +//! tmpfs or the scratch, with the record's journal policy, and its overlay mode, and with the +//! inputs that the guest takes from its own configuration, which its template captured, a +//! [`RootTemplate`]: the runtime directory, the block devices, and how long the assembly waits for +//! each, but never past the activation's deadline, which the adapter passes, so that the +//! application ends by then. For a GPT disk, it first asks the kernel to reread the disk's +//! partition table, once the distro device appeared, by the same deadline: the kernel created the +//! block devices of partitions from the template's placeholder, when it found it, and creates those +//! of the clone's disk then, from the same read-only bytes that the assembly reads and checks, as +//! at a cold boot. The upper must be fresh: the assembly refuses one whose `upper` or `work` +//! directory exists and is not empty, since a clone's root starts as its image alone, while a cold +//! sandbox's scratch keeps its upper across boots; the check reads at most one entry of each, and +//! no file. If the record has a workload identity, it then checks its account in the assembled +//! root, before Ready, as NVX's init checks it at boot: the identity is //! [`Identity::new`](super::entry::Identity::new) of the record's user and group IDs, without //! supplementary groups or capabilities, in existing-account mode, and every execution runs as //! it. Without one, the adapter gives each execution its own identity, which @@ -242,8 +248,9 @@ //! its own reaping, transport, and shutdown. The activation needs no privilege, and makes no //! system call: of the clone, it changes only the caller's fence, which it opens. Its //! application, [`apply`](fn@apply), needs the guest's root user, in the guest's initial mount -//! namespace, as [`root::assemble`] does: it mounts the root, reads the account databases below -//! it, and, if it fails, tears it down. It starts no process and generates no ID. +//! namespace, as [`root::assemble`] does: for a GPT disk, it asks the kernel to reread the disk's +//! partition table, which needs `CAP_SYS_ADMIN`; it mounts the root, reads the account databases +//! below it, and, if it fails, tears it down. It starts no process and generates no ID. mod activation; mod mounts; diff --git a/nvx-rs/src/guest/clone/activation/apply.rs b/nvx-rs/src/guest/clone/activation/apply.rs index a19d8241a..c9d06bcfb 100644 --- a/nvx-rs/src/guest/clone/activation/apply.rs +++ b/nvx-rs/src/guest/clone/activation/apply.rs @@ -12,6 +12,7 @@ use std::time::{Duration, Instant}; #[cfg(doc)] use super::Activation; use crate::activation::{self, ActivationRecord, Configuration, WorkloadIdentity}; +use crate::gpt::Expected; #[cfg(doc)] use crate::guest::entry::AccountMode; use crate::guest::entry::{self, Entry, Identity}; @@ -84,17 +85,29 @@ impl Applied { /// In order, it: /// /// 1. takes the inputs of the root's [`Assembly`] from the record: its image, an EROFS image, -/// with the UUID that the record expects of it, if any; its upper, a tmpfs, or the scratch, -/// with the record's journal policy; and its overlay mode; and, from `template`, the runtime -/// directory, the devices, and the timeout. It refuses a GPT disk, with -/// [`ApplyError::Gpt`], before anything has a side effect; -/// 2. assembles the root as [`root::assemble`] does, which checks the devices, and the image as -/// the record expects it, and mounts the root, or fails with [`ApplyError::Assembly`], once it -/// unmounted what it mounted. It waits for each device as long as the template's timeout -/// allows, but never past `deadline`: a device that has not appeared by then fails the -/// assembly, as at its timeout, so that the application ends by the activation's deadline, -/// past which [`Activation::complete`] fails the activation as expired, whatever the result; -/// 3. if the record has a workload identity, checks its account in the assembled root, before +/// with the UUID that the record expects of it, if any, or a GPT disk, with the disk GUID that +/// the record expects of it, if any, and the bound of its first partition; its upper, a tmpfs, +/// or the scratch, with the record's journal policy; and its overlay mode; and, from +/// `template`, the runtime directory, the devices, and the timeout; +/// 2. for a GPT disk, asks the kernel to reread the disk's partition table, once the distro +/// device appeared, so that the block devices of the disk's partitions are those of the +/// clone's disk, rather than those of the template's placeholder, which the kernel created +/// when it found the placeholder: the kernel then has parsed the same read-only bytes that +/// the assembly reads and checks, as at a cold boot. It waits for the distro device as the +/// assembly does, never past `deadline`. A failure, before anything is mounted, fails with +/// [`ApplyError::Gpt`]; +/// 3. assembles the root as [`root::assemble`] does, which checks the devices, and the image as +/// the record expects it, and mounts the root, but with a fresh upper: it refuses the upper's +/// `upper` or `work` directory if it exists and is not empty, with +/// [`root::Error::NotFresh`], since a clone's root starts as its image alone, while a cold +/// sandbox's scratch keeps its upper across boots. That check reads at most one entry of +/// each directory, and no file. A failure fails with [`ApplyError::Assembly`], once the +/// assembly unmounted what it mounted. It waits for each device as long as the template's +/// timeout allows, but never past `deadline`: a device that has not appeared by then fails +/// the assembly, as at its timeout, so that the application ends by the activation's +/// deadline, past which [`Activation::complete`] fails the activation as expired, whatever +/// the result; +/// 4. if the record has a workload identity, checks its account in the assembled root, before /// Ready, as NVX's init checks it at boot: the identity is [`Identity::new`] of the record's /// user and group IDs, without supplementary groups or capabilities, in /// [existing-account](AccountMode::Existing) mode, whose account [`Entry::prepare`] reads and @@ -124,12 +137,15 @@ pub fn apply( #[derive(Debug)] #[non_exhaustive] pub enum ApplyError { - /// The record's image is a GPT disk, which the application refuses before anything has a - /// side effect: the kernel created the block devices of a disk's partitions when it found the - /// template's placeholder, so they need not be the clone's. - Gpt, + /// The partition table of the record's GPT disk could not be reread, before anything was + /// mounted, as this error tells: the distro device did not appear in time, or the kernel + /// refused the reread, as it does while one of the disk's partitions is open. The block + /// devices of the disk's partitions need not be the clone's then: the kernel created them + /// when it found the template's placeholder. + Gpt(root::Error), /// The root's assembly failed, as this error tells, and unmounted what it had mounted, unless - /// the error holds the unmounts that failed. + /// the error holds the unmounts that failed. It fails with [`root::Error::NotFresh`] if the + /// upper's `upper` or `work` directory exists and is not empty. Assembly(AssemblyError), /// Existing-account mode refused the record's workload identity in the assembled root, which /// the application then tore down. @@ -146,8 +162,10 @@ pub enum ApplyError { impl fmt::Display for ApplyError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Gpt => formatter - .write_str("the record's image is a GPT disk, which a clone does not assemble"), + Self::Gpt(error) => write!( + formatter, + "the reread of the GPT disk's partition table failed: {error}" + ), Self::Assembly(error) => write!(formatter, "the root's assembly failed: {error}"), Self::Account { error, teardown } => { write!(formatter, "the root refuses the workload identity: {error}")?; @@ -163,7 +181,7 @@ impl fmt::Display for ApplyError { impl std::error::Error for ApplyError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { - Self::Gpt => None, + Self::Gpt(error) => Some(error), Self::Assembly(error) => Some(error), Self::Account { error, .. } => Some(error), } @@ -176,8 +194,18 @@ trait System { /// What an assembly mounted: a [`Root`], in a VM. type Root; - /// Assembles the root of `assembly`, as [`root::assemble`] does, but waits for each device - /// until `deadline` at the latest, if any. + /// Asks the kernel to reread the partition table of the GPT disk on the distro device + /// `distro`, once it appeared, waiting for it for as long as `timeout` allows, but until + /// `deadline` at the latest, if any, as [`root::reread_partitions`] does. + fn reread( + &mut self, + distro: Device, + timeout: Duration, + deadline: Option, + ) -> Result<(), root::Error>; + + /// Assembles the root of `assembly` with a fresh upper, as [`root::assemble_fresh`] does, + /// waiting for each device until `deadline` at the latest, if any. fn assemble( &mut self, assembly: &Assembly, @@ -197,12 +225,21 @@ struct LiveSystem; impl System for LiveSystem { type Root = Root; + fn reread( + &mut self, + distro: Device, + timeout: Duration, + deadline: Option, + ) -> Result<(), root::Error> { + root::reread_partitions(distro, timeout, deadline) + } + fn assemble( &mut self, assembly: &Assembly, deadline: Option, ) -> Result { - root::assemble_until(assembly, deadline) + root::assemble_fresh(assembly, deadline) } fn account(&mut self, root: &Root, identity: &Identity) -> Result<(), entry::Error> { @@ -224,8 +261,13 @@ fn apply_with( deadline: Option, ) -> Result<(S::Root, Option), ApplyError> { let configuration = record.configuration(); - let assembly = assembly(template, configuration)?; + let assembly = assembly(template, configuration); let identity = configuration.workload.identity.map(workload_identity); + if let root::Image::Gpt { .. } = assembly.image { + system + .reread(assembly.distro, assembly.timeout, deadline) + .map_err(ApplyError::Gpt)?; + } let root = system .assemble(&assembly, deadline) .map_err(ApplyError::Assembly)?; @@ -239,14 +281,22 @@ fn apply_with( } /// Returns the assembly of the root that `configuration` sets, with `template`'s runtime -/// directory, devices, and timeout, or refuses a GPT disk. -fn assembly( - template: &RootTemplate, - configuration: &Configuration, -) -> Result { +/// directory, devices, and timeout. +fn assembly(template: &RootTemplate, configuration: &Configuration) -> Assembly { let image = match configuration.image { activation::Image::Erofs { uuid, .. } => root::Image::Erofs { uuid }, - activation::Image::Gpt { .. } => return Err(ApplyError::Gpt), + activation::Image::Gpt { + disk_guid, + first_partition_bound, + .. + } => root::Image::Gpt { + expected: match disk_guid { + Some(guid) => Expected::new().disk_guid(guid), + None => Expected::new(), + }, + // A larger bound than the guest can address bounds nothing more. + first_partition_bound: usize::try_from(first_partition_bound).unwrap_or(usize::MAX), + }, }; let upper = match configuration.upper { activation::Upper::Tmpfs => root::Upper::Tmpfs, @@ -262,14 +312,14 @@ fn assembly( activation::OverlayMode::TrustedLowers => root::OverlayMode::TrustedLowers, activation::OverlayMode::UntrustedLowers => root::OverlayMode::UntrustedLowers, }; - Ok(Assembly { + Assembly { runtime: template.runtime.clone(), distro: template.distro, image, upper, overlay, timeout: template.timeout, - }) + } } /// Returns the identity in which every execution of a workload with `identity` runs: its user diff --git a/nvx-rs/src/guest/clone/activation/apply/tests.rs b/nvx-rs/src/guest/clone/activation/apply/tests.rs index e832d7ae9..333489885 100644 --- a/nvx-rs/src/guest/clone/activation/apply/tests.rs +++ b/nvx-rs/src/guest/clone/activation/apply/tests.rs @@ -25,6 +25,7 @@ use crate::guest::clone::{ ActivationState, AdmissionFence, }; use crate::guest::entry::{AccountError, AccountMode, Capabilities, Database}; +use crate::guest::sys::Errno; use crate::protocol::root::{DISTRO_MMIO_BASE, SCRATCH_MMIO_BASE}; use crate::uuid::Uuid; @@ -42,6 +43,10 @@ const TIMEOUT: Duration = Duration::from_secs(10); const TRANSACTION: TransactionId = TransactionId::from_bytes([0x22; TransactionId::LEN]); /// The UUID that the record of the tests' Activate expects of its EROFS image. const IMAGE_UUID: Uuid = Uuid::from_bytes([0x5a; 16]); +/// The GUID that a record of the tests expects of its GPT disk. +const DISK_GUID: Uuid = Uuid::from_bytes([0xd1; 16]); +/// The most bytes that the first partition of the tests' GPT disks may hold. +const FIRST_PARTITION_BOUND: u64 = 4096; /// The tests' workload identity, whose account the tests' image holds. const WORKLOAD: WorkloadIdentity = WorkloadIdentity { uid: 1000, @@ -63,7 +68,10 @@ type Refusal = fn(&AccountError) -> bool; /// execution that the tests' adapter spawned. #[derive(Clone, Debug, PartialEq, Eq)] enum Effect { - /// The application assembled the root of this assembly, which mounts it. + /// The application asked the kernel to reread the partition table of the GPT disk on this + /// distro device, which drops and creates the block devices of the disk's partitions. + Reread(Device), + /// The application assembled the root of this assembly, with a fresh upper, which mounts it. Assemble(Assembly), /// The application checked this identity's account in the root, which reads the image's /// account databases. @@ -82,9 +90,11 @@ struct FakeSystem { image: tempfile::TempDir, /// Whether each assembly fails. failing: bool, + /// The error with which the kernel refuses each reread of a partition table, if it does. + refusing: Option, /// What the clone did, in order. effects: Vec, - /// The deadline of each assembly, in order, by which it waits for its devices. + /// The deadline of each reread and assembly, in order, by which it waits for its devices. deadlines: Vec>, } @@ -99,6 +109,7 @@ impl FakeSystem { Self { image, failing: false, + refusing: None, effects: Vec::new(), deadlines: Vec::new(), } @@ -118,6 +129,16 @@ impl FakeSystem { } } + /// Returns a system whose image holds the tests' account databases, and whose kernel refuses + /// each reread of a partition table with `error`, as while one of the disk's partitions is + /// open. + fn refusing(error: Errno) -> Self { + Self { + refusing: Some(error), + ..Self::new() + } + } + /// Returns the path of the root that each assembly returns. fn root(&self) -> PathBuf { self.image.path().to_owned() @@ -127,6 +148,25 @@ impl FakeSystem { impl System for FakeSystem { type Root = PathBuf; + fn reread( + &mut self, + distro: Device, + _timeout: Duration, + deadline: Option, + ) -> Result<(), root::Error> { + self.effects.push(Effect::Reread(distro)); + self.deadlines.push(deadline); + match self.refusing { + Some(error) => Err(root::Error::Device { + role: root::Role::Distro, + step: root::DeviceStep::Reread, + device: PathBuf::from("/dev/vda"), + error: error.into(), + }), + None => Ok(()), + } + } + fn assemble( &mut self, assembly: &Assembly, @@ -303,12 +343,25 @@ fn erofs(uuid: Option) -> activation::Image { } } -/// Returns a GPT disk, without a GUID that the record expects of it. -fn gpt() -> activation::Image { +/// Returns a GPT disk, with the GUID `disk_guid` that the record expects of it, if any, whose +/// first partition holds at most [`FIRST_PARTITION_BOUND`] bytes. +fn gpt(disk_guid: Option) -> activation::Image { activation::Image::Gpt { binding: activation::Identity::from_bytes([0x11; activation::Identity::LEN]), - disk_guid: None, - first_partition_bound: 4096, + disk_guid, + first_partition_bound: FIRST_PARTITION_BOUND, + } +} + +/// Returns the image of the root's assembly of a GPT disk of [`gpt`]`(disk_guid)`. +fn gpt_image(disk_guid: Option) -> root::Image { + let expected = crate::gpt::Expected::new(); + root::Image::Gpt { + expected: match disk_guid { + Some(guid) => expected.disk_guid(guid), + None => expected, + }, + first_partition_bound: FIRST_PARTITION_BOUND as usize, } } @@ -421,7 +474,7 @@ fn no_mount_spawn_or_id_generation_happens_before_the_gate() { }) .encode(); let disk = clone_record(Configuration { - image: gpt(), + image: gpt(None), ..settings }) .encode(); @@ -464,17 +517,28 @@ fn no_mount_spawn_or_id_generation_happens_before_the_gate() { adapter.assert_untouched(&failure, expected); } - // The application refuses a GPT disk, which the activation accepted, before it mounts - // anything. - let mut adapter = Adapter::restricted(FakeSystem::new(), base); + // A GPT disk, which the activation accepted, whose partition table the kernel refuses to + // reread, fails the application before anything is mounted: the reread is its one call. + let mut adapter = Adapter::restricted(FakeSystem::refusing(Errno::EBUSY), base); let failure = adapter .take(base, ActivationInput::Activate(&disk)) .unwrap_err(); - adapter.assert_untouched(&failure, |cause| { - matches!(cause, ActivationCause::Failed(error) - if matches!(error.downcast_ref::(), Some(ApplyError::Gpt))) - }); + assert!( + matches!( + apply_error(&failure), + ApplyError::Gpt(root::Error::Device { + step: root::DeviceStep::Reread, + .. + }) + ), + "{failure}" + ); assert_eq!(failure.state(), ActivationState::Activating); + assert_eq!(adapter.activation.state(), ActivationState::Failed); + assert_eq!(adapter.system.effects, [Effect::Reread(template().distro)]); + assert_eq!(adapter.applied, None); + assert_eq!(adapter.ready, None); + assert!(!adapter.fence.is_open()); // Past the deadline, even a valid Activate does nothing. let mut adapter = Adapter::restricted(FakeSystem::new(), base); @@ -560,9 +624,19 @@ fn a_valid_activate_activates_the_root_and_the_workload_once_before_ready() { #[test] fn assembly_and_identity_inputs_are_taken_exactly_from_the_record() { let template = template(); + // Each image, with what the assembly expects of it, and whether the kernel rereads its + // partition table first. let images = [ - (erofs(None), None), - (erofs(Some(IMAGE_UUID)), Some(IMAGE_UUID)), + (erofs(None), root::Image::Erofs { uuid: None }, false), + ( + erofs(Some(IMAGE_UUID)), + root::Image::Erofs { + uuid: Some(IMAGE_UUID), + }, + false, + ), + (gpt(None), gpt_image(None), true), + (gpt(Some(DISK_GUID)), gpt_image(Some(DISK_GUID)), true), ]; let uppers = [ (activation::Upper::Tmpfs, root::Upper::Tmpfs), @@ -591,7 +665,7 @@ fn assembly_and_identity_inputs_are_taken_exactly_from_the_record() { root::OverlayMode::UntrustedLowers, ), ]; - for (image, uuid) in images { + for (image, root_image, reread) in images { for (upper, root_upper) in uppers { for (overlay, root_overlay) in overlays { for identity in [None, Some(WORKLOAD)] { @@ -603,13 +677,17 @@ fn assembly_and_identity_inputs_are_taken_exactly_from_the_record() { let assembly = Assembly { runtime: template.runtime.clone(), distro: template.distro, - image: root::Image::Erofs { uuid }, + image: root_image.clone(), upper: root_upper, overlay: root_overlay, timeout: template.timeout, }; let identity = identity.map(|_| workload()); - let mut effects = vec![Effect::Assemble(assembly)]; + let mut effects = Vec::new(); + if reread { + effects.push(Effect::Reread(template.distro)); + } + effects.push(Effect::Assemble(assembly)); effects.extend(identity.clone().map(Effect::Account)); assert_eq!(system.effects, effects, "{record:?}"); assert_eq!(applied, identity); @@ -652,6 +730,50 @@ fn assembly_and_identity_inputs_are_taken_exactly_from_the_record() { assert_eq!(first_identity, second_identity); } +#[test] +fn a_gpt_disk_s_partition_table_is_reread_before_its_root_is_assembled() { + // The kernel rereads the table from the clone's disk before the assembly reads and checks + // it, as at a cold boot, where it read the table when it found the disk; the clone then + // activates as with an EROFS image, over a fresh upper. + let base = Instant::now(); + let record = clone_record(configuration( + gpt(Some(DISK_GUID)), + scratch(activation::Journal::Forbidden), + activation::OverlayMode::UntrustedLowers, + Some(WORKLOAD), + )); + let mut adapter = Adapter::restricted(FakeSystem::new(), base); + adapter + .take(base, ActivationInput::Activate(&record.encode())) + .unwrap(); + let template = template(); + let assembly = Assembly { + runtime: template.runtime.clone(), + distro: template.distro, + image: gpt_image(Some(DISK_GUID)), + upper: root::Upper::Scratch { + device: template.scratch, + journal: root::Journal::Forbidden, + }, + overlay: root::OverlayMode::UntrustedLowers, + timeout: template.timeout, + }; + assert_eq!( + adapter.system.effects, + [ + Effect::Reread(template.distro), + Effect::Assemble(assembly), + Effect::Account(workload()), + ] + ); + // The reread waited for the distro device, and the assembly for each device, until the + // activation's deadline at the latest. + assert_eq!(adapter.system.deadlines, [Some(base + TIMEOUT); 2]); + assert_eq!(adapter.activation.state(), ActivationState::Ready); + assert_eq!(adapter.ready, Some(ReadyActivation::of(&record))); + assert!(adapter.fence.is_open()); +} + #[test] fn a_workload_identity_without_an_account_in_the_image_is_refused_before_ready() { let base = Instant::now(); @@ -766,18 +888,8 @@ fn a_failed_assembly_is_final_and_reads_and_tears_down_nothing() { #[test] fn the_application_refuses_without_privileges_what_it_cannot_assemble() { - // A GPT disk is refused before anything is mounted, and a template's runtime directory - // reaches the assembly, which refuses a relative one before it does anything else. - let disk = clone_record(configuration( - gpt(), - activation::Upper::Tmpfs, - activation::OverlayMode::TrustedLowers, - Some(WORKLOAD), - )); - assert!(matches!( - apply(&disk, &template(), None), - Err(ApplyError::Gpt) - )); + // A template's runtime directory reaches the assembly, which refuses a relative one before it + // does anything else. let relative = RootTemplate { runtime: PathBuf::from("run/nvx"), ..template() @@ -789,15 +901,52 @@ fn the_application_refuses_without_privileges_what_it_cannot_assemble() { matches!(error.error(), root::Error::Runtime { path } if path == Path::new("run/nvx")), "{error}" ); + + // A GPT disk's partition table is reread before that refusal: on a host without the + // template's distro device, the reread fails at its one poll, with nothing mounted. + let disk = clone_record(configuration( + gpt(None), + activation::Upper::Tmpfs, + activation::OverlayMode::TrustedLowers, + Some(WORKLOAD), + )); + let at_once = RootTemplate { + timeout: Duration::ZERO, + ..relative + }; + let Err(ApplyError::Gpt(error)) = apply(&disk, &at_once, None) else { + panic!("a GPT disk was assembled before its partition table was reread"); + }; + // A host that hides its MMIO windows fails the lookup instead. + assert!( + matches!( + error, + root::Error::Missing { + role: root::Role::Distro, + mmio: DISTRO_MMIO_BASE, + } | root::Error::Lookup { + role: root::Role::Distro, + mmio: DISTRO_MMIO_BASE, + .. + } + ), + "{error}" + ); } #[test] fn application_errors_name_their_cause() { + let missing = root::Error::Missing { + role: root::Role::Distro, + mmio: DISTRO_MMIO_BASE, + }; + let refusal = missing.to_string(); + let error = ApplyError::Gpt(missing); assert_eq!( - ApplyError::Gpt.to_string(), - "the record's image is a GPT disk, which a clone does not assemble" + error.to_string(), + format!("the reread of the GPT disk's partition table failed: {refusal}") ); - assert!(ApplyError::Gpt.source().is_none()); + assert_eq!(error.source().unwrap().to_string(), refusal); let relative = Assembly { runtime: PathBuf::from("run/nvx"), diff --git a/nvx-rs/src/guest/root.rs b/nvx-rs/src/guest/root.rs index e64c82c3a..b9556fa80 100644 --- a/nvx-rs/src/guest/root.rs +++ b/nvx-rs/src/guest/root.rs @@ -295,6 +295,9 @@ pub enum DeviceStep { Open, /// Reading the status of the node of a GPT disk's partition. Status, + /// Rereading the partition table of the GPT disk on it, which a clone asks of the kernel before + /// it assembles its root. + Reread, } impl fmt::Display for DeviceStep { @@ -307,6 +310,7 @@ impl fmt::Display for DeviceStep { Self::Access => "read the access mode of", Self::Open => "open", Self::Status => "read the status of", + Self::Reread => "reread the partition table of", }) } } @@ -344,8 +348,9 @@ pub enum Image { }, /// A GPT disk, whose partition table an assembly reads and checks through /// [`guest::gpt::read_disk`](super::gpt::read_disk), and whose partitions it uses as the - /// kernel created them when it found the disk: it neither compares them with the table nor - /// asks the kernel to reread it. Its first partition holds opaque bytes, which the assembly + /// kernel created them when it found the disk, or when it last reread the table, as a clone + /// asks of it before its assembly: the assembly neither compares them with the table nor asks + /// the kernel to reread it. Its first partition holds opaque bytes, which the assembly /// returns through [`Root::first_partition`], and never probes or mounts. Each later /// partition, from partition 2 on, holds a layer, an ext4 file system that the assembly /// mounts at `layers/pN`, for the partition's number N, `ro,nosuid,nodev`, with @@ -501,15 +506,78 @@ pub(crate) fn assemble_until( &Paths::default(), assembly, deadline, + Contents::Kept, ) } -/// Implements [`assemble_until`] with `kernel`, and with the kernel's interfaces at `paths`. +/// Assembles the root of `assembly` as [`assemble_until`] does, waiting for each device until +/// `deadline` at the latest, if any, but with a fresh upper, as a clone's application asks: once +/// it mounted the upper, and before it creates either directory, it refuses the upper's `upper` or +/// `work` directory if it exists and is not empty, with [`Error::NotFresh`]. A cold sandbox's +/// scratch keeps its upper across boots, but a clone's root must start as its image alone, since +/// the overlay would show what an earlier root wrote. A tmpfs, which the assembly mounts empty, +/// always passes. +/// +/// The check is bounded, and reads no file's content: of each directory, it reads the status, +/// and at most one entry besides `.` and `..`, and it opens nothing below it. +pub(crate) fn assemble_fresh( + assembly: &Assembly, + deadline: Option, +) -> Result { + assemble_with( + Box::new(SystemKernel), + &Paths::default(), + assembly, + deadline, + Contents::Fresh, + ) +} + +/// Asks the kernel to reread the partition table of the GPT disk on the distro device that the +/// VMM attached at `distro`'s MMIO window, once the device appeared, polling for it as an +/// assembly does, for as long as `timeout` allows, but never past `deadline`, if any. A clone's +/// application asks this before it assembles a root of the disk: the kernel then drops the block +/// devices of the partitions that it created when it found the template's placeholder, and +/// creates those of the clone's disk, and devtmpfs their nodes, from the same read-only bytes that +/// the assembly reads and checks next, as at a cold boot. It checks nothing else of the device, +/// which the assembly checks. +/// +/// It needs `CAP_SYS_ADMIN`, and the kernel refuses it while one of the disk's partitions is +/// open, as a mounted one is. +pub(crate) fn reread_partitions( + distro: Device, + timeout: Duration, + deadline: Option, +) -> Result<(), Error> { + block::reread( + &mut SystemKernel, + &Paths::default(), + distro, + timeout, + deadline, + ) + .map(drop) +} + +/// What an assembly accepts in the upper's `upper` and `work` directories, which it creates +/// unless they exist. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Contents { + /// Whatever they hold, which the root keeps, as [`assemble`] accepts: a cold sandbox's + /// scratch keeps its upper across boots. + Kept, + /// Nothing: each must be missing or empty, as [`assemble_fresh`] requires. + Fresh, +} + +/// Implements [`assemble_until`] and [`assemble_fresh`] with `kernel`, and with the kernel's +/// interfaces at `paths`, accepting the upper's `contents`. fn assemble_with( kernel: Box, paths: &Paths, assembly: &Assembly, deadline: Option, + contents: Contents, ) -> Result { let runtime = assembly.runtime.as_os_str().as_bytes(); if below_its_root(&assembly.runtime, true).is_none() @@ -538,7 +606,7 @@ fn assemble_with( latched: false, reservation: None, }; - match root.mount_layers(paths, assembly, deadline) { + match root.mount_layers(paths, assembly, deadline, contents) { Ok(()) => Ok(root), Err(error) => Err(AssemblyError { error, @@ -683,12 +751,13 @@ impl Root { } /// Implements [`assemble`]'s steps after the first, waiting for each device until `deadline` - /// at the latest, if any. + /// at the latest, if any, and accepting the upper's `contents`. fn mount_layers( &mut self, paths: &Paths, assembly: &Assembly, deadline: Option, + contents: Contents, ) -> Result<(), Error> { let runtime = self.runtime.clone(); fs::create_dir_all(&runtime).map_err(|error| Error::MountPoint { @@ -843,6 +912,11 @@ impl Root { } }; self.upper = Some((mount, upper_point)); + if contents == Contents::Fresh { + for directory in [&upper, &work] { + check_fresh(directory)?; + } + } for directory in [&upper, &work] { create_scratch_directory(directory).map_err(|error| Error::MountPoint { path: directory.clone(), @@ -1275,6 +1349,37 @@ fn create_scratch_directory(path: &Path) -> io::Result<()> { Ok(()) } +/// Refuses the upper's directory `path`, its `upper` or `work`, if it exists and is not empty, as +/// a fresh upper's may not be, and anything there but a directory, a symbolic link included, as +/// [`create_directory`] does. It reads the status of `path`, and, of a directory, at most one +/// entry besides `.` and `..`, in whichever order the file system lists them; it opens nothing +/// below it. +fn check_fresh(path: &Path) -> Result<(), Error> { + let failed = |error: io::Error| Error::MountPoint { + path: path.to_owned(), + error, + }; + match fs::symlink_metadata(path) { + Ok(status) if status.is_dir() => {} + Ok(_) => return Err(failed(Errno::ENOTDIR.into())), + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(failed(error)), + } + match fs::read_dir(path).and_then(holds_entry) { + Ok(false) => Ok(()), + Ok(true) => Err(Error::NotFresh { + path: path.to_owned(), + }), + Err(error) => Err(failed(error)), + } +} + +/// Returns whether the directory listing `entries`, which lists no `.` or `..`, holds an entry, +/// from its first alone. +fn holds_entry(mut entries: impl Iterator>) -> io::Result { + entries.next().transpose().map(|entry| entry.is_some()) +} + /// A mount of a layered root. #[derive(Clone, Debug, PartialEq, Eq, Hash)] #[non_exhaustive] @@ -1494,6 +1599,13 @@ pub enum Error { /// The device's node. device: PathBuf, }, + /// The upper's `upper` or `work` directory exists and is not empty, which an assembly that + /// requires a fresh upper refuses, as a clone's application does, whose root must start as its + /// image alone. + NotFresh { + /// The directory. + path: PathBuf, + }, /// The overlay's options are longer than [`MOUNT_OPTIONS_LIMIT`]: its lower directories, with /// the runtime directory's path, are too many or too long for one mount. OverlayOptions { @@ -1653,6 +1765,11 @@ impl fmt::Display for Error { "the scratch device {} has an ext4 journal, which the assembly forbids", device.display() ), + Self::NotFresh { path } => write!( + formatter, + "the upper's directory {} is not empty, but the upper must be fresh", + path.display() + ), Self::OverlayOptions { length } => write!( formatter, "the overlay's options take {length} bytes, more than the \ diff --git a/nvx-rs/src/guest/root/block.rs b/nvx-rs/src/guest/root/block.rs index 413b3e170..05b917fe7 100644 --- a/nvx-rs/src/guest/root/block.rs +++ b/nvx-rs/src/guest/root/block.rs @@ -1,5 +1,6 @@ //! The block devices that the VMM attaches in the root's roles, which the base addresses of their -//! MMIO windows name, and the block devices of a GPT disk's partitions. +//! MMIO windows name, and the block devices of a GPT disk's partitions, whose table a clone asks +//! the kernel to reread. //! //! The VMM declares each device on the kernel command line, so the kernel names its MMIO window //! `virtio-mmio.N` in `/proc/iomem`, and creates its block device below the window's device in @@ -119,6 +120,30 @@ pub(super) fn prepare( Ok(path) } +/// Finds the distro device that the VMM attached at `device`'s MMIO window, polling until it +/// appears for as long as `timeout` allows, but never past `deadline`, if any, as [`prepare`] +/// does, and asks the kernel to reread the partition table of the disk on it, which drops the +/// block devices of the partitions that the kernel created from the table that the disk held +/// before, and creates those of the table that it holds now, and their nodes. It checks nothing +/// else of the device, whose checks [`prepare`] makes. Returns the path of its node. +pub(super) fn reread( + kernel: &mut dyn Kernel, + paths: &Paths, + device: Device, + timeout: Duration, + deadline: Option, +) -> Result { + let (_, path) = resolve(kernel, paths, Role::Distro, device.mmio, timeout, deadline)?; + let reread = sys::path_c_string(&path).and_then(|node| kernel.reread_partitions(&node)); + reread.map_err(|error| Error::Device { + role: Role::Distro, + step: DeviceStep::Reread, + device: path.clone(), + error: error.into(), + })?; + Ok(path) +} + /// Returns the name and the node of the block device that the VMM attached at `mmio`, polling /// for it every [`POLL_INTERVAL`] while `timeout` allows another poll, or, if `deadline` comes /// first, until the deadline, at which it polls once more, after a shorter sleep: it sleeps no @@ -162,10 +187,11 @@ fn resolve( /// node is `disk`, in order. /// /// The kernel created the block devices of the disk's partitions from the table that the disk -/// held when the kernel found it, and devtmpfs created their nodes beside the disk's, under the -/// names that the kernel gives them: the disk's name and the partition's number, with a `p` -/// between them if the disk's name ends with a digit, as `vda2` and `nvme0n1p2` are. It waits for -/// none: a partition whose node is missing fails at once. +/// held when the kernel found it, or when it last reread the table, as a clone asks of it, and +/// devtmpfs created their nodes beside the disk's, under the names that the kernel gives them: +/// the disk's name and the partition's number, with a `p` between them if the disk's name ends +/// with a digit, as `vda2` and `nvme0n1p2` are. It waits for none: a partition whose node is +/// missing fails at once. pub(super) fn partition_nodes(disk: &Path, numbers: &[u8]) -> Result, Error> { numbers .iter() diff --git a/nvx-rs/src/guest/root/kernel.rs b/nvx-rs/src/guest/root/kernel.rs index 7b1d287f9..564e9ab71 100644 --- a/nvx-rs/src/guest/root/kernel.rs +++ b/nvx-rs/src/guest/root/kernel.rs @@ -3,11 +3,11 @@ //! The operations read the kernel's text files and walk directories through the standard //! library, at the paths of a [`Paths`], so that a test relocates them to a temporary directory. //! What a test cannot do in a temporary directory, such as a mount, an unmount, a flush of a -//! block device's buffers, or a read of a GPT disk's partition table at the geometry that its -//! block device reports, and the writes to the kernel's control files, the check for a mount -//! point, and the options of a mount, whose effects and answers a test simulates, go through a -//! [`Kernel`], which also performs the mapper's operations for the root's live shares and -//! mappings. +//! block device's buffers, a reread of a disk's partition table, or a read of a GPT disk's +//! partition table at the geometry that its block device reports, and the writes to the kernel's +//! control files, the check for a mount point, and the options of a mount, whose effects and +//! answers a test simulates, go through a [`Kernel`], which also performs the mapper's operations +//! for the root's live shares and mappings. use std::ffi::CStr; use std::fs::{self, File}; @@ -27,11 +27,11 @@ pub(crate) use crate::guest::mappings::SystemKernel; const MOUNT_TABLE: &str = "/proc/thread-self/mountinfo"; /// The kernel's operations that the root operations perform, other than reads: mounts and -/// unmounts, flushes of a block device's buffers, writes of a control file, and the sleeps -/// between two polls for a block device; the check for a mount point and the options of a mount, -/// which depend on them; since only a block device reports its geometry, the read of a GPT disk's -/// partition table; and the mapper's operations, through which the root mounts its live shares -/// and mappings. +/// unmounts, flushes of a block device's buffers, rereads of a disk's partition table, writes of +/// a control file, and the sleeps between two polls for a block device; the check for a mount +/// point and the options of a mount, which depend on them; since only a block device reports its +/// geometry, the read of a GPT disk's partition table; and the mapper's operations, through which +/// the root mounts its live shares and mappings. /// /// [`SystemKernel`] performs them. The tests' fake records the operations, simulates the effect /// of a cgroup's membership file and its own mounts, reads a GPT disk's table from a regular @@ -54,6 +54,10 @@ pub(super) trait Kernel: MappingKernel + Send + Sync { /// Writes the dirty buffers of the block device `device`, then drops its cached ones. fn flush(&mut self, device: &CStr) -> Result<(), Errno>; + /// Asks the kernel to reread the partition table of the disk whose whole-disk block device is + /// `device`, as [`sys::reread_partition_table`] does. + fn reread_partitions(&mut self, device: &CStr) -> Result<(), Errno>; + /// Reads and checks the partition table of the GPT disk that `file` opens, the whole-disk /// block device whose node is `device`, with the caller's `expected` values, at the device's /// logical block size and size, as [`gpt::read_disk`] does. @@ -108,6 +112,16 @@ impl Kernel for SystemKernel { descriptor.close() } + fn reread_partitions(&mut self, device: &CStr) -> Result<(), Errno> { + let descriptor = sys::open( + device, + OpenFlags::O_RDONLY | OpenFlags::O_CLOEXEC, + Mode::empty(), + )?; + sys::reread_partition_table(&descriptor)?; + descriptor.close() + } + fn read_disk( &mut self, _device: &Path, @@ -185,7 +199,8 @@ impl Default for Paths { #[cfg(test)] pub(crate) use fake::{Call, FakeKernel, Operation, attach_calls}; -/// The tests' kernel, which records each operation and performs no mount, unmount, or flush. +/// The tests' kernel, which records each operation and performs no mount, unmount, flush, or +/// reread. #[cfg(test)] mod fake { use std::ffi::{CStr, OsStr}; @@ -225,6 +240,8 @@ mod fake { Unmount, /// A flush of a block device's buffers, which the device names. Flush, + /// A reread of a disk's partition table, which the disk's block device names. + Reread, /// A write of a control file, which its path names. Write, /// An open beneath a directory, which the opened path names. @@ -270,6 +287,8 @@ mod fake { Unmount(String), /// A flush of a block device's buffers. Flush(String), + /// A reread of the partition table of a disk, which its block device's node names. + Reread(String), /// A read of the partition table of the GPT disk on a block device, which its node names. ReadDisk(String), /// A write of a value to a control file. @@ -381,8 +400,8 @@ mod fake { } } - /// A kernel that records each mount operation, performs no mount, unmount, or flush, and - /// fails the operations that a test chooses. + /// A kernel that records each mount operation, performs no mount, unmount, flush, or reread + /// of a partition table, and fails the operations that a test chooses. /// /// A write of a control file writes the regular file at its path, which it creates if /// needed, except that a write of a process ID to a cgroup's `cgroup.procs` moves the ID @@ -739,6 +758,10 @@ mod fake { self.record(Call::Flush(text(device)), Operation::Flush, &path(device)) } + fn reread_partitions(&mut self, device: &CStr) -> Result<(), Errno> { + self.record(Call::Reread(text(device)), Operation::Reread, &path(device)) + } + fn read_disk( &mut self, device: &Path, diff --git a/nvx-rs/src/guest/root/tests.rs b/nvx-rs/src/guest/root/tests.rs index 73641c855..87de4981e 100644 --- a/nvx-rs/src/guest/root/tests.rs +++ b/nvx-rs/src/guest/root/tests.rs @@ -149,9 +149,15 @@ impl Guest { } } - /// Assembles `assembly` with a clone of the guest's kernel. + /// Assembles `assembly` with a clone of the guest's kernel, as [`assemble`] does. fn assemble(&self, assembly: &Assembly) -> Result { - assemble_with(Box::new(self.kernel.clone()), &self.paths, assembly, None) + assemble_with( + Box::new(self.kernel.clone()), + &self.paths, + assembly, + None, + Contents::Kept, + ) } /// Assembles `assembly` with a clone of the guest's kernel, waiting for each device until @@ -162,7 +168,25 @@ impl Guest { deadline: Instant, ) -> Result { let kernel = Box::new(self.kernel.clone()); - assemble_with(kernel, &self.paths, assembly, Some(deadline)) + assemble_with( + kernel, + &self.paths, + assembly, + Some(deadline), + Contents::Kept, + ) + } + + /// Assembles `assembly` with a clone of the guest's kernel and a fresh upper, as + /// [`assemble_fresh`] does. + fn assemble_fresh(&self, assembly: &Assembly) -> Result { + assemble_with( + Box::new(self.kernel.clone()), + &self.paths, + assembly, + None, + Contents::Fresh, + ) } /// Sets the overlay module's parameter `name`, the default of one of the overlay's options: @@ -925,6 +949,137 @@ fn new_upper_and_work_directories_have_mode_0755_whatever_the_umask() { assert_eq!(mode(&guest.path("run/nvx/tmpfs/work")), 0o755); } +#[test] +fn a_fresh_upper_s_directories_must_be_missing_or_empty() { + let mode = |path: &Path| fs::metadata(path).unwrap().mode() & 0o7777; + // A scratch or a tmpfs without the directories gets them, as any root's upper does, with no + // other call than a cold root's. + for upper in [scratch_upper(Journal::Allowed), Upper::Tmpfs] { + let guest = Guest::new(); + let assembly = Assembly { + upper, + ..guest.assembly() + }; + guest.assemble_fresh(&assembly).unwrap().teardown().unwrap(); + let point = upper_point(upper.kind()); + for directory in ["upper", "work"] { + let path = guest.path(&format!("run/nvx/{point}/{directory}")); + assert_eq!(mode(&path), 0o755, "{directory}"); + } + assert_eq!( + guest.kernel.mounts(), + below(&guest.text("run/nvx"), &["layers/distro", point, "rootfs"]) + ); + } + + // Empty directories that the scratch holds already pass, and keep their mode. + let guest = Guest::new(); + for directory in ["upper", "work"] { + let path = guest.path("run/nvx/scratch").join(directory); + fs::create_dir_all(&path).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + } + guest + .assemble_fresh(&guest.assembly()) + .unwrap() + .teardown() + .unwrap(); + for directory in ["upper", "work"] { + assert_eq!(mode(&guest.path("run/nvx/scratch").join(directory)), 0o700); + } + + // A directory that holds an entry, such as the work directory that an earlier overlay + // left, is refused once the upper is mounted, before the overlay, and before the other + // directory is created; the failure unmounts the layer and the upper, and leaves the entry. + for (directory, entry, other) in [("upper", "etc", "work"), ("work", "work", "upper")] { + let guest = Guest::new(); + let path = guest.path("run/nvx/scratch").join(directory); + fs::create_dir_all(path.join(entry)).unwrap(); + let error = guest.assemble_fresh(&guest.assembly()).unwrap_err(); + assert!( + matches!(error.error(), Error::NotFresh { path: refused } if *refused == path), + "{error:?}" + ); + assert!(error.cleanup().is_none()); + assert_eq!( + error.to_string(), + format!( + "the upper's directory {} is not empty, but the upper must be fresh", + path.display() + ) + ); + let mounted = below(&guest.text("run/nvx"), &["layers/distro", "scratch"]); + assert_eq!(guest.kernel.mounts(), mounted); + assert_eq!(guest.kernel.unmounts(), mounted); + assert!(path.join(entry).is_dir()); + assert!(!guest.path("run/nvx/scratch").join(other).exists()); + } + + // So is anything there but a directory, a symbolic link to an empty one included, as any + // root's assembly refuses it. + for link in [false, true] { + let guest = Guest::new(); + let upper = guest.path("run/nvx/scratch/upper"); + if link { + fs::create_dir_all(guest.path("elsewhere")).unwrap(); + fs::create_dir_all(guest.path("run/nvx/scratch")).unwrap(); + std::os::unix::fs::symlink(guest.path("elsewhere"), &upper).unwrap(); + } else { + write(&upper, "a file"); + } + let error = guest.assemble_fresh(&guest.assembly()).unwrap_err(); + assert!( + matches!(error.error(), Error::MountPoint { path, error } + if *path == upper && error.raw_os_error() == Some(Errno::ENOTDIR as i32)), + "{error:?}" + ); + } + + // A cold root keeps both, whatever they hold, as a scratch keeps its upper across boots. + let guest = Guest::new(); + for path in ["run/nvx/scratch/upper/etc", "run/nvx/scratch/work/work"] { + fs::create_dir_all(guest.path(path)).unwrap(); + } + guest + .assemble(&guest.assembly()) + .unwrap() + .teardown() + .unwrap(); + assert!(guest.path("run/nvx/scratch/upper/etc").is_dir()); + assert!(guest.path("run/nvx/scratch/work/work").is_dir()); +} + +#[test] +fn the_freshness_check_takes_one_entry_of_a_directory_and_opens_none() { + // However many entries a directory holds, the check refuses it at its first, and opens + // none: not a FIFO, whose open would block, nor a directory below it. + let guest = Guest::new(); + let upper = guest.path("run/nvx/scratch/upper"); + fs::create_dir_all(upper.join("a/b/c")).unwrap(); + for index in 0..1024 { + fs::write(upper.join(format!("file{index}")), "content").unwrap(); + } + let fifo = sys::path_c_string(&upper.join("fifo")).unwrap(); + sys::create_fifo(&fifo, sys::Mode::from_bits_truncate(0o600)).unwrap(); + let error = guest.assemble_fresh(&guest.assembly()).unwrap_err(); + assert!( + matches!(error.error(), Error::NotFresh { path } if *path == upper), + "{error:?}" + ); + + // The listing's first entry decides, and the check takes no other. + let mut taken = 0; + let listing = std::iter::from_fn(|| { + taken += 1; + Some(Ok::<_, io::Error>(())) + }); + assert!(holds_entry(listing).unwrap()); + assert_eq!(taken, 1); + assert!(!holds_entry(std::iter::empty::>()).unwrap()); + let failed = holds_entry(std::iter::once(Err::<(), _>(os_error(Errno::EIO)))); + assert_eq!(failed.unwrap_err().raw_os_error(), Some(Errno::EIO as i32)); +} + #[test] fn a_runtime_directory_that_another_root_holds_is_refused() { let guest = Guest::new(); @@ -973,7 +1128,13 @@ fn a_runtime_directory_that_another_root_holds_is_refused() { if sleeps == 1 { attach(&directory, 0, "vda", 4096); let calls = kernel.calls().len(); - let result = assemble_with(Box::new(kernel.clone()), &paths, &assembly, None); + let result = assemble_with( + Box::new(kernel.clone()), + &paths, + &assembly, + None, + Contents::Kept, + ); *outcome.lock().unwrap() = Some((result.map(drop), kernel.calls().len() - calls)); } }); @@ -1230,8 +1391,14 @@ fn a_runtime_directory_where_a_directory_is_bound_is_refused() { runtime, ..guest.assembly() }; - let error = - assemble_with(Box::new(SystemKernel), &guest.paths, &assembly, None).unwrap_err(); + let error = assemble_with( + Box::new(SystemKernel), + &guest.paths, + &assembly, + None, + Contents::Kept, + ) + .unwrap_err(); assert!( matches!(error.error(), Error::Occupied { path } if *path == distro), "{error:?}" @@ -2252,6 +2419,245 @@ fn a_missing_partition_fails_at_once_before_any_mount() { assert!(guest.kernel.mounts().is_empty()); } +#[test] +fn a_reread_asks_the_kernel_for_the_distro_disk_s_table_once_the_device_appears() { + // The reread finds the distro device by its window, as an assembly does, and only asks the + // kernel to reread the disk's table: it checks, flushes, reads, and mounts nothing. + let guest = Guest::new(); + let distro = guest.assembly().distro; + let timeout = Duration::from_millis(100); + let node = block::reread( + &mut guest.kernel.clone(), + &guest.paths, + distro, + timeout, + None, + ) + .unwrap(); + assert_eq!(node, guest.path("dev/vda")); + assert_eq!(guest.kernel.calls(), [Call::Reread(guest.text("dev/vda"))]); + + // It waits for the device as long as the timeout allows, and asks nothing of one that does + // not appear. + let guest = Guest::bare(); + let directory = guest.base.clone(); + guest.kernel.on_sleep(move |sleeps| { + if sleeps == 1 { + attach(&directory, 0, "vda", 4096); + } + }); + let waiting = Duration::from_secs(1); + block::reread( + &mut guest.kernel.clone(), + &guest.paths, + distro, + waiting, + None, + ) + .unwrap(); + assert_eq!(guest.kernel.sleeps(), 1); + assert_eq!(guest.kernel.calls(), [Call::Reread(guest.text("dev/vda"))]); + let guest = Guest::bare(); + let error = block::reread( + &mut guest.kernel.clone(), + &guest.paths, + distro, + timeout, + None, + ) + .unwrap_err(); + assert!( + matches!( + error, + Error::Missing { + role: Role::Distro, + mmio: DISTRO, + } + ), + "{error:?}" + ); + assert_eq!(guest.kernel.sleeps(), 2); + assert!(guest.kernel.calls().is_empty()); + + // However long its timeout, one whose deadline passed looks for the device once, without + // sleeping, as an assembly does, and asks nothing of a missing one. + let guest = Guest::bare(); + let patient = Duration::from_secs(600); + let error = block::reread( + &mut guest.kernel.clone(), + &guest.paths, + distro, + patient, + Some(Instant::now()), + ) + .unwrap_err(); + assert!( + matches!( + error, + Error::Missing { + role: Role::Distro, + mmio: DISTRO, + } + ), + "{error:?}" + ); + assert_eq!(guest.kernel.sleeps(), 0); + assert!(guest.kernel.calls().is_empty()); + + // The kernel's refusal, as while one of the disk's partitions is open, names the device. + let guest = Guest::new(); + let vda = guest.path("dev/vda"); + guest.kernel.fail(Operation::Reread, &vda, Errno::EBUSY); + let error = block::reread( + &mut guest.kernel.clone(), + &guest.paths, + distro, + timeout, + None, + ) + .unwrap_err(); + assert!( + matches!( + &error, + Error::Device { + role: Role::Distro, + step: DeviceStep::Reread, + device, + error, + } if *device == vda && error.raw_os_error() == Some(Errno::EBUSY as i32) + ), + "{error:?}" + ); + assert_eq!( + error.to_string(), + format!( + "failed to reread the partition table of the distro device {}: {}", + vda.display(), + os_error(Errno::EBUSY) + ) + ); +} + +/// The loop device of a test, which it detaches once dropped. +struct LoopDevice(PathBuf); + +impl LoopDevice { + /// Attaches the disk image `image`, read-only and with its partitions scanned, as the kernel + /// scans those of a virtio disk, or returns `None` without `losetup`. + fn attach(image: &Path) -> Option { + let output = std::process::Command::new("losetup") + .args(["--find", "--show", "--read-only", "--partscan"]) + .arg(image) + .output(); + let output = match output { + Ok(output) => output, + Err(error) if error.kind() == io::ErrorKind::NotFound => return None, + Err(error) => panic!("cannot run losetup: {error}"), + }; + assert!(output.status.success(), "{output:?}"); + let node = String::from_utf8(output.stdout).unwrap(); + Some(Self(PathBuf::from(node.trim_end()))) + } + + /// Returns the number, start, and size, in 512-byte sectors, of each partition that the + /// kernel created, as sysfs lists them, in order. + fn partitions(&self) -> Vec<(u64, u64, u64)> { + let name = self.0.file_name().unwrap().to_str().unwrap(); + let disk = Path::new("/sys/class/block").join(name); + let mut partitions = Vec::new(); + for entry in fs::read_dir(&disk).unwrap() { + let entry = entry.unwrap().path(); + if !entry.join("partition").exists() { + continue; + } + let number = |file: &str| { + let text = fs::read_to_string(entry.join(file)).unwrap(); + text.trim_end().parse::().unwrap() + }; + partitions.push((number("partition"), number("start"), number("size"))); + } + partitions.sort_unstable(); + partitions + } + + /// Returns the node of partition `number`. + fn partition(&self, number: u8) -> PathBuf { + let mut node = self.0.clone().into_os_string(); + node.push(format!("p{number}")); + node.into() + } +} + +impl Drop for LoopDevice { + fn drop(&mut self) { + let _ = std::process::Command::new("losetup") + .arg("--detach") + .arg(&self.0) + .status(); + } +} + +/// Waits until udev, if the host runs it, handled its events, during which its probes may hold +/// a partition open. +fn settle() { + let _ = std::process::Command::new("udevadm").arg("settle").status(); +} + +/// Writes `message` to the standard error, past the test harness's capture, as the guest's +/// programs write theirs. +fn report(message: &str) { + use std::io::Write; + + let _ = writeln!(io::stderr(), "{message}"); +} + +#[test] +#[ignore = "needs root and a loop device"] +fn the_kernel_rereads_a_partition_table_that_changed_under_it() { + // A read-only loop device stands for a clone's distro device: the kernel scanned it when it + // found the template's placeholder, and the disk now holds the clone's, of another layout. + if !nix::unistd::geteuid().is_root() { + report("skipped: the test needs root"); + return; + } + let directory = tempfile::tempdir().unwrap(); + let image = directory.path().join("disk"); + let placeholder = GptDisk::new(b"placeholder", 3).image(); + let clone = GptDisk::new(&[0x5a; 600], 1).image(); + let length = placeholder.len().max(clone.len()); + let padded = |disk: &[u8]| { + let mut disk = disk.to_vec(); + disk.resize(length, 0); + disk + }; + fs::write(&image, padded(&placeholder)).unwrap(); + let Some(device) = LoopDevice::attach(&image) else { + report("skipped: the test needs losetup"); + return; + }; + settle(); + // Both disks' tables take 34 blocks: the MBR, the header, and 128 entries of 128 bytes. The + // placeholder's first partition takes one block and the clone's two, then each layer four. + let found = [(1, 34, 1), (2, 35, 4), (3, 39, 4), (4, 43, 4)]; + assert_eq!(device.partitions(), found); + fs::write(&image, padded(&clone)).unwrap(); + assert_eq!(device.partitions(), found); + + // The reread drops the placeholder's partitions, and creates the clone's, with their nodes, + // from the disk's bytes, before it returns. + let node = sys::path_c_string(&device.0).unwrap(); + SystemKernel.reread_partitions(&node).unwrap(); + assert_eq!(device.partitions(), [(1, 34, 2), (2, 36, 4)]); + assert!(device.partition(2).exists()); + assert!(!device.partition(3).exists()); + + // While one of the disk's partitions is open, the kernel refuses to reread its table. + settle(); + let open = fs::File::open(device.partition(2)).unwrap(); + assert_eq!(SystemKernel.reread_partitions(&node), Err(Errno::EBUSY)); + drop(open); +} + #[test] fn the_first_partition_is_returned_as_opaque_bytes_within_its_bound() { // One whole sector of bytes that are not UTF-8, with no padding. diff --git a/nvx-rs/src/guest/sys/ioctl.rs b/nvx-rs/src/guest/sys/ioctl.rs index 8047e0a19..63601bc4d 100644 --- a/nvx-rs/src/guest/sys/ioctl.rs +++ b/nvx-rs/src/guest/sys/ioctl.rs @@ -57,6 +57,13 @@ nix::ioctl_none!( 97 ); +nix::ioctl_none!( + /// Rereads a disk's partition table: Linux `BLKRRPART`, `_IO(0x12, 95)`. + blkrrpart, + 0x12, + 95 +); + nix::ioctl_read_bad!( /// Reads a block device's logical block size: Linux `BLKSSZGET`, `_IO(0x12, 104)`, which /// writes an `int` although its number declares no argument. @@ -116,6 +123,19 @@ pub(crate) fn flush_block_buffers(descriptor: &FileDescriptor) -> Result<(), Err Ok(()) } +/// Asks Linux to reread the partition table of the disk whose whole-disk block device +/// `descriptor` opened, as `blockdev --rereadpt` does: the kernel drops the disk's cached buffers +/// and the block devices of its partitions, reads the table from the device, and creates the +/// block devices of the partitions that it finds, whose nodes devtmpfs creates, before the request +/// returns. It needs `CAP_SYS_ADMIN`, and fails with `EBUSY` while one of the disk's partitions is +/// open, as a mounted one is, and with `EINVAL` on a partition, or on a disk that the kernel does +/// not partition. +pub(crate) fn reread_partition_table(descriptor: &FileDescriptor) -> Result<(), Errno> { + // SAFETY: `BLKRRPART` reads no argument. + unsafe { blkrrpart(descriptor.raw()) }?; + Ok(()) +} + /// Returns the logical block size, in bytes, of the block device that `descriptor` opened, as /// `blockdev --getss` prints it. /// @@ -181,6 +201,22 @@ mod tests { assert_eq!(nix::request_code_none!(0x12, 97) as u32, 0x1261); } + #[test] + fn the_partition_table_reread_ioctl_number_matches_linux() { + // `BLKRRPART` in linux/fs.h. + assert_eq!(nix::request_code_none!(0x12, 95) as u32, 0x125f); + } + + #[test] + fn a_partition_table_reread_fails_on_other_files() { + use crate::guest::sys::{Mode, OpenFlags, open, path_c_string}; + + let file = tempfile::NamedTempFile::new().unwrap(); + let path = path_c_string(file.path()).unwrap(); + let descriptor = open(&path, OpenFlags::O_RDONLY, Mode::empty()).unwrap(); + assert_eq!(reread_partition_table(&descriptor), Err(Errno::ENOTTY)); + } + #[test] fn the_block_geometry_ioctl_numbers_match_linux() { // `BLKSSZGET` and `BLKGETSIZE64` in linux/fs.h. The latter is `_IOR(0x12, 114, size_t)`: diff --git a/nvx-rs/src/guest/sys/mod.rs b/nvx-rs/src/guest/sys/mod.rs index 7c01eb6ec..7f649a84e 100644 --- a/nvx-rs/src/guest/sys/mod.rs +++ b/nvx-rs/src/guest/sys/mod.rs @@ -105,7 +105,8 @@ pub(crate) use filesystem::{ read_line, read_number, unmount, write_file, write_number, }; pub(crate) use ioctl::{ - block_device_size, console_pending, flush_block_buffers, logical_block_size, reseed_random, + block_device_size, console_pending, flush_block_buffers, logical_block_size, + reread_partition_table, reseed_random, }; pub(crate) use mapping::MemoryMapping; #[cfg(target_arch = "x86_64")] diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index 828afc381..5c2c3a3c6 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -798,12 +798,17 @@ after the repair's acknowledgement of the restore: 3. It applies the record with `nvx::guest::clone::apply`, with `worker::capture::root_template` of the template's configuration: its runtime directory, the `distro` and scratch block devices, and its device - timeout. The application assembles the root, an EROFS image over a tmpfs - or the scratch, in the record's overlay mode, waiting for each device until - the activation's deadline at the latest, so that it ends by then, and checks - the account of the record's workload identity, if it has one, in the - assembled root, in existing-account mode. It applies none of the record's - other settings: its export, its network, its hostname, and its machine ID. + timeout. The application assembles the root, an EROFS image or a GPT disk + over a tmpfs or the scratch, in the record's overlay mode, waiting for each + device until the activation's deadline at the latest, so that it ends by + then, and checks the account of the record's workload identity, if it has + one, in the assembled root, in existing-account mode. For a GPT disk, it + first asks the kernel to reread the disk's partition table, so that the + block devices of its partitions are the clone's disk's, not the template's + placeholder's. The upper must be fresh: the assembly refuses one whose + `upper` or `work` directory exists and is not empty. It applies none of the + record's other settings: its export, its network, its hostname, and its + machine ID. 4. Once it took what the session received while the application ran, which came before `Ready`, it completes the activation, which opens the fence, and answers `Ready`, which reports the activation after the guest agent's @@ -866,7 +871,7 @@ ends with one of these statuses, after a diagnostic on standard error: | 51 | The host broke the clone's session: it reset the session again, or sent another request, before the clone was ready, or an `Activate` of another transaction, or one that changes the accepted record | | 52 | The `Activate`'s record does not decode, breaks a limit, or is a cold boot's | | 53 | The record expects other resources than the clone has | -| 54 | The root's assembly failed, or the record's image is a GPT disk, which a clone does not assemble | +| 54 | The root's assembly failed, its upper not fresh among its causes, or the kernel did not reread the partition table of the record's GPT disk before it | | 55 | The image refuses the record's workload identity: it holds no account of the identity, or not its group, or its account databases cannot be read | | 58 | The worker's own order misused the activation: it completed an activation that did not run, or started the sandbox before the clone was ready, or again | | 193 | A conformance check of the time ABI failed in the clone's repair, such as the finish's, which needs a running time daemon | @@ -1967,11 +1972,12 @@ restore of a template's snapshot started, with `worker::work_clone`, behind its test broker, with the session that the template's VM attached, active in the epoch of its broker's first reset, and the clone's activation, `nvx::guest::clone::Activation::with_resources`, behind a closed fence. The fake -root applies each record as `nvx::guest::clone::apply` does, without a root: it -refuses a GPT disk, takes as long as a test asks, but no longer than until the -activation's deadline, as the waits for the devices do, and checks the account -of the record's workload identity in the tests' directory of account databases. -The tests cover: +root applies each record as `nvx::guest::clone::apply` does, without a root: for +a GPT disk, it stands for the kernel's reread of the disk's partition table, +which a test may fail; it takes as long as a test asks, but no longer than until +the activation's deadline, as the waits for the devices do, and checks the +account of the record's workload identity in the tests' directory of account +databases. The tests cover: - the reset that the restore queued, of another instance, which the worker acknowledges without an attachment, and the `Activate`, whose record it @@ -1985,9 +1991,10 @@ The tests cover: reset, which fail the activation with the status of the host's session, with nothing answered or applied; - a record that does not decode, a cold boot's, and one that expects other - resources, which fail the activation with nothing applied, and a GPT disk and - a workload identity whose account or group the image lacks, which fail its - application, each with the status of its class, and no `Ready`; + resources, which fail the activation with nothing applied, and a GPT disk + whose partition table the kernel does not reread, and a workload identity + whose account or group the image lacks, which fail its application, each + with the status of its class, and no `Ready`; - an `Activate` whose header announces more than 1 KiB, which the framing refuses before it takes any byte of its body, and which fails the activation with the record's status, before the clone is ready and once it is; diff --git a/tests/runtime-adapter/src/worker.rs b/tests/runtime-adapter/src/worker.rs index 6bbea49e3..701d6e3bf 100644 --- a/tests/runtime-adapter/src/worker.rs +++ b/tests/runtime-adapter/src/worker.rs @@ -80,11 +80,12 @@ //! effect. //! 3. The worker applies the record through `RootOperations::apply`, which runs //! `nvx::guest::clone::apply`: it assembles the root from the template's devices, with the -//! record's image, upper, and overlay mode, waiting for each device until the activation's -//! deadline at the latest, so that the application ends by then, and checks the account of the -//! record's workload identity, if it has one, in the assembled root, as the worker of a VM that -//! boots cold does for a root configuration. The template bootstrapped the guest for its root -//! with its own configuration before its capture, so the clone has its cgroups. +//! record's image, upper, and overlay mode, once the kernel reread the partition table of a +//! GPT disk, over a fresh upper, waiting for each device until the activation's deadline at +//! the latest, so that the application ends by then, and checks the account of the record's +//! workload identity, if it has one, in the assembled root, as the worker of a VM that boots +//! cold does for a root configuration. The template bootstrapped the guest for its root with +//! its own configuration before its capture, so the clone has its cgroups. //! 4. Once it took what the session received while the application ran, which came before //! `Ready`, it completes the activation, which opens the fence, and answers `Ready`, which //! reports that the activation applied, with the record's transaction, after the guest agent's diff --git a/tests/runtime-adapter/src/worker/activation.rs b/tests/runtime-adapter/src/worker/activation.rs index 4684e1995..13608df8e 100644 --- a/tests/runtime-adapter/src/worker/activation.rs +++ b/tests/runtime-adapter/src/worker/activation.rs @@ -72,9 +72,10 @@ pub const ACTIVATION_PROBE: &str = "activation"; /// breaks a limit, or is a cold boot's; /// - [`ACTIVATION_RESOURCES`](status::ACTIVATION_RESOURCES): the record expects other resources /// than the repaired clone has; -/// - [`ACTIVATION_ROOT`](status::ACTIVATION_ROOT): the root's assembly failed, or the record's -/// image is a GPT disk, which a clone does not assemble yet, as -/// [`ApplyError::Assembly`] and [`ApplyError::Gpt`] tell, or the application failed otherwise; +/// - [`ACTIVATION_ROOT`](status::ACTIVATION_ROOT): the root's assembly failed, the scratch's upper +/// is not fresh among its causes, or the kernel did not reread the partition table of the +/// record's GPT disk before it, as [`ApplyError::Assembly`] and [`ApplyError::Gpt`] tell, or the +/// application failed otherwise; /// - [`ACTIVATION_WORKLOAD`](status::ACTIVATION_WORKLOAD): the image refuses the record's /// workload identity, as [`ApplyError::Account`] tells; /// - [`ACTIVATION_ORDER`](status::ACTIVATION_ORDER): the worker's own order completed an @@ -92,7 +93,8 @@ pub fn failure_status(failure: &ActivationFailure) -> u8 { ActivationCause::Resources { .. } => status::ACTIVATION_RESOURCES, ActivationCause::Failed(error) => match error.downcast_ref::() { Some(ApplyError::Account { .. }) => status::ACTIVATION_WORKLOAD, - // The root's assembly, a GPT image, and any other failure of the application. + // The root's assembly, the reread of a GPT disk's partition table, and any other + // failure of the application. _ => status::ACTIVATION_ROOT, }, ActivationCause::Completion | ActivationCause::Start => status::ACTIVATION_ORDER, @@ -421,6 +423,8 @@ mod tests { }; use nvx::guest::clone::AdmissionFence; use nvx::guest::entry; + use nvx::guest::root::{Error as RootError, Role}; + use nvx::protocol::root::DISTRO_MMIO_BASE; use super::*; @@ -544,7 +548,11 @@ mod tests { let failure = failed(more.input(now(), ActivationInput::Activate(&bytes))); cases.push((failure, status::ACTIVATION_RESOURCES)); // The application: of the root, of the workload, and any other. - let root = activating().complete(now(), Err(ApplyError::Gpt.into())); + let reread = ApplyError::Gpt(RootError::Missing { + role: Role::Distro, + mmio: DISTRO_MMIO_BASE, + }); + let root = activating().complete(now(), Err(reread.into())); cases.push((failed(root), status::ACTIVATION_ROOT)); let refused = ApplyError::Account { error: entry::Error::Root(io::Error::other("no account")), diff --git a/tests/runtime-adapter/tests/guest_session.rs b/tests/runtime-adapter/tests/guest_session.rs index b72653a1e..adb047074 100644 --- a/tests/runtime-adapter/tests/guest_session.rs +++ b/tests/runtime-adapter/tests/guest_session.rs @@ -1121,6 +1121,9 @@ enum Operation { Bootstrap, /// The assembly. Assemble, + /// A clone's reread of its GPT disk's partition table, before its assembly. + #[cfg(target_arch = "x86_64")] + Reread, /// The host export's mount. MountExport, /// A probe. @@ -1334,10 +1337,12 @@ impl RootOperations for FakeRoot { } /// Applies the record as `nvx::guest::clone::apply` does, without a root, once the test opened - /// the gate, if any: it refuses a GPT disk before anything else, takes as long as the - /// assembly, but no longer than until `deadline`, as the waits for the devices do, and checks - /// the account of the record's workload identity, if it has one, in existing-account mode, in - /// the [accounts' root](accounts), which stands for the root that it assembled. + /// the gate, if any: for a GPT disk, it stands for the kernel's reread of the disk's partition + /// table first, which fails as the kernel does while a partition is open if the test fails + /// [`Operation::Reread`]; it then takes as long as the assembly, but no longer than until + /// `deadline`, as the waits for the devices do, and checks the account of the record's + /// workload identity, if it has one, in existing-account mode, in the + /// [accounts' root](accounts), which stands for the root that it assembled. #[cfg(target_arch = "x86_64")] fn apply( &mut self, @@ -1346,6 +1351,7 @@ impl RootOperations for FakeRoot { deadline: Option, ) -> Result, nvx::guest::clone::ApplyError> { use nvx::guest::clone::ApplyError; + use nvx::guest::root::{DeviceStep, Error, Role}; let applied = (record.clone(), template.clone()); self.calls.push(Call::Apply(Box::new(applied))); @@ -1356,7 +1362,14 @@ impl RootOperations for FakeRoot { } let configuration = record.configuration(); if matches!(configuration.image, nvx::activation::Image::Gpt { .. }) { - return Err(ApplyError::Gpt); + self.result(Operation::Reread).map_err(|problem| { + ApplyError::Gpt(Error::Device { + role: Role::Distro, + step: DeviceStep::Reread, + device: PathBuf::from("/dev/vda"), + error: io::Error::other(problem), + }) + })?; } let assembly = match deadline { Some(deadline) => self @@ -4344,8 +4357,9 @@ mod clones { assert_eq!(root.calls, [], "{message:?}"); } - // The application refuses a GPT disk, which a clone does not assemble, and a workload - // identity whose account the image lacks, or whose primary group it lacks, before Ready. + // The application fails once the kernel refused to reread a GPT disk's partition table, + // and refuses a workload identity whose account the image lacks, or whose primary group + // it lacks, before Ready. let gpt = changed(&clone, |configuration| { configuration.image = activation::Image::Gpt { binding: activation::Identity::from_bytes([0x11; 32]), @@ -4354,13 +4368,24 @@ mod clones { }; }); let cases = [ - (gpt, status::ACTIVATION_ROOT), - (record(1, Some((4242, 4242))), status::ACTIVATION_WORKLOAD), - (record(1, Some(ORPHAN)), status::ACTIVATION_WORKLOAD), + ( + gpt, + FakeRoot::failing(Operation::Reread, "the disk is busy"), + status::ACTIVATION_ROOT, + ), + ( + record(1, Some((4242, 4242))), + FakeRoot::booting(&layout()), + status::ACTIVATION_WORKLOAD, + ), + ( + record(1, Some(ORPHAN)), + FakeRoot::booting(&layout()), + status::ACTIVATION_WORKLOAD, + ), ]; - for (record, expected) in cases { - let (mut broker, working) = - start_clone(FakeRoot::booting(&layout()), ACTIVATION_TIMEOUT); + for (record, root, expected) in cases { + let (mut broker, working) = start_clone(root, ACTIVATION_TIMEOUT); broker.reset(RESTORED); broker.send_data(&activate(&record).encode().unwrap(), OUTER_MAX_PAYLOAD); let root = unactivated(&mut broker, working, expected); From 41a6e8ec0bef2ce7e2521dd31427218ae6ca8eed Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sun, 25 Oct 2026 19:11:08 -0700 Subject: [PATCH 2/3] nvx-rs: restore clones of GPT disks A clone's plan refused a GPT disk as its image before any VMM started: the kernel of a clone's guest had created the block devices of the partitions of the template's placeholder, and no clone rebuilt them. The guest of a GPT clone now asks the kernel to reread the disk's partition table before it assembles the root, so the plan takes a GPT disk as it takes an EROFS layer. The record of a GPT clone names the disk: the identity of its binding, the GUID that the adapter's plan expects of it, and the bound of its first partition, which CloneRequest gains, as first_partition_bound, beside the journal policy. An EROFS layer has no partitions, so the record of a clone of one leaves the bound out. A bound of 0 is a setting that no record holds, and the plan refuses it before it opens anything, as it refuses the request's other Activate settings, with the evidence InvalidActivate. InvalidFormat now names only an image that is not of the format and identity that its binding names. The plan checks a GPT disk as it checks an EROFS layer, from its metadata alone: the check of its binding, and the geometry of the template's placeholder, whatever the placeholder's format. As at a cold launch, the guest reads and checks the partition table with nvx::gpt before it assembles the root, so the host reads none of the disk, and hashes nothing. The test adapter's clone requests bound a GPT disk's first partition by 64 KiB, the default of a launch's guest. Part of #845 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80932e09-af82-4b79-b417-70a241303cda --- nvx-rs/README.md | 21 +-- nvx-rs/src/host/template/clone.rs | 75 +++++---- nvx-rs/src/host/template/clone/failure.rs | 3 +- nvx-rs/src/host/template/clone/record.rs | 32 ++-- nvx-rs/src/host/template/clone/tests.rs | 150 +++++++++++++++--- tests/runtime-adapter/README.md | 28 ++-- .../src/bin/runtime-adapter-host.rs | 9 +- tests/runtime-adapter/src/clone.rs | 14 +- 8 files changed, 231 insertions(+), 101 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 14c839af9..45f7f5009 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -661,15 +661,18 @@ sandbox's directory, which the release removes; and a fresh capability. It builds the clone's activation record, `ClonePlan::activation`, which the clone's guest applies in place of the configuration that the template captured, -from the request's `WorkloadView`, overlay mode, and journal policy, and from -the checked inputs: a restore section with a fresh transaction ID of 16 random -bytes and the lease's `TemplateReference`; the image, an EROFS layer, with its -binding's identity and the UUID that the adapter's plan expects of it, since a -GPT image is refused; a tmpfs, or the scratch image's binding identity with the -journal policy; no export; the template NIC's policy identity, as its key -records it, unpublished listeners, and the guest's proxy URL; and the template's -processors and the memory target. A binding's identity is the SHA-256 digest of -a domain and of the binding's serde form, never of a file's content. +from the request's `WorkloadView`, overlay mode, journal policy, and bound of a +GPT disk's first partition, and from the checked inputs: a restore section with +a fresh transaction ID of 16 random bytes and the lease's `TemplateReference`; +the image, an EROFS layer or a GPT disk, as its binding names it, with its +binding's identity and the filesystem UUID or disk GUID that the adapter's plan +expects of it, and for a GPT disk the bound of its first partition, whose +partition table the guest checks with `nvx::gpt` as at a cold launch; a tmpfs, +or the scratch image's binding identity with the journal policy; no export; the +template NIC's policy identity, as its key records it, unpublished listeners, +and the guest's proxy URL; and the template's processors and the memory target. +A binding's identity is the SHA-256 digest of a domain and of the binding's +serde form, never of a file's content. The image and the scratch device must present the `BlockGeometry` of the template's, which a restore compares, and be, at the paths by which OpenVMM diff --git a/nvx-rs/src/host/template/clone.rs b/nvx-rs/src/host/template/clone.rs index fa0cfe003..626262e68 100644 --- a/nvx-rs/src/host/template/clone.rs +++ b/nvx-rs/src/host/template/clone.rs @@ -68,11 +68,12 @@ const SPARSE_WHAT: &str = "scratch device of the clone"; /// scratch image of an upper on one, and the memory target, which [`ClonePlan::new`] checks /// against the generation, and the settings of the clone's activation that the template does not /// fix, which the clone's [activation record](ClonePlan::activation) names: the workload's view -/// of its VM, the overlay's mode, and the journal policy of a scratch image. +/// of its VM, the overlay's mode, the journal policy of a scratch image, and the bound of a GPT +/// disk's first partition. #[derive(Debug, Clone)] pub struct CloneRequest { /// The adapter's plan, which [`SandboxPlan::adapter`] makes, of the clone's image, its - /// `distro`, an EROFS layer, over its upper: in RAM, a tmpfs, as + /// `distro`, an EROFS layer or a GPT disk, over its upper: in RAM, a tmpfs, as /// [`SandboxUpper::RAM`](crate::host::sandbox::SandboxUpper::RAM) plans it, or on the /// caller's scratch image. Its network is the one of the template's NIC, which every restore /// of the template attaches, or none for a template without a NIC: on the NIC's guest @@ -101,6 +102,10 @@ pub struct CloneRequest { /// Whether the filesystem of the scratch image of an upper on one may have a journal. An /// upper in RAM has none, so the record of a clone in RAM leaves the policy out. pub journal: Journal, + /// The most bytes that the first partition of a GPT disk may hold, at least 1, which the + /// guest checks as it reads the disk's partition table. An EROFS layer has no partitions, so + /// the record of a clone of one leaves the bound out. + pub first_partition_bound: u64, } /// The upper of a clone's plan, with the file of the clone's `scratch` block device, which every @@ -132,13 +137,16 @@ pub enum CloneUpper { /// compares with those that the template recorded. The plan checks each of the clone's inputs as /// OpenVMM will find it, and holds what the checks opened: /// -/// - The image, the clone's `distro`, must pass the check of its binding, -/// [`DistroBinding::check`], and present the geometry of the template's placeholder, as its -/// key records it: the placeholder's length, and the same logical and physical block sizes, -/// which [`CheckedDistro::geometry`] reads through the checked file. At its path, it must be a -/// regular file, not a link, nor on Windows any other reparse point, that is the file that the -/// check found, that belongs to the current user or to an administrator, and that no one else -/// may change. The plan holds the file that the check opened, for reading alone. +/// - The image, the clone's `distro`, an EROFS layer or a GPT disk, as its binding names it, must +/// pass the check of its binding, [`DistroBinding::check`], and present the geometry of the +/// template's placeholder, as its key records it: the placeholder's length, and the same logical +/// and physical block sizes, which [`CheckedDistro::geometry`] reads through the checked file, +/// whatever the placeholder's format. At its path, it must be a regular file, not a link, nor on +/// Windows any other reparse point, that is the file that the check found, that belongs to the +/// current user or to an administrator, and that no one else may change. The plan holds the +/// file that the check opened, for reading alone. As at a cold launch, the guest reads and +/// checks a GPT disk's partition table, with `nvx::gpt`, before it assembles the root, so the +/// plan reads none of it. /// - Neither the image nor a scratch image may be a file that the sandbox's store owns, by any /// path, through a link among its directories, say, or a hard link: the store's transitions /// replace or remove its files, as a plan replaces the file of the scratch device of a clone in @@ -176,8 +184,8 @@ pub enum CloneUpper { /// - The activation record, which the clone's guest applies in place of the configuration that /// the template captured, names the restore, with a fresh transaction ID, the clone's image, /// upper, and settings, the template's network, and the resources that the clone must have, -/// as [`activation`](Self::activation) describes. Its image must be an EROFS layer, and its -/// settings must keep the limits of a record, as [`Configuration::check`] checks them. +/// as [`activation`](Self::activation) describes. Its settings must keep the limits of a +/// record, as [`Configuration::check`] checks them. /// - The capability is fresh: the plan draws it for the clone's launch, [`launch`](Self::launch). /// /// The plan's checks read the metadata of the clone's files, never their content. @@ -222,6 +230,7 @@ pub enum CloneUpper { /// }, /// overlay: OverlayMode::UntrustedLowers, /// journal: Journal::Allowed, +/// first_partition_bound: 64 * 1024, /// }; /// let deadline = Instant::now() + Duration::from_secs(5); /// let plan = ClonePlan::new(store, &mut acquired, &request, deadline)?; @@ -307,15 +316,13 @@ impl ClonePlan { /// /// - with [`ErrorKind::Unsupported`] for a request that no clone takes: a plan of the sandbox /// profile, with workload settings and its shares, [`Evidence::InvalidSettings`]; a plan - /// whose image is not the format and identity that the binding names, as for a binding - /// whose identity is not a UUID in its canonical text, and an image that is a GPT disk, of - /// which this build restores no clone, since a clone's guest assembles its root only from an - /// EROFS layer, [`Evidence::InvalidFormat`]; an image or a scratch image whose path is - /// relative, which OpenVMM would resolve against the directory in which it runs, holds a - /// comma or a semicolon, which OpenVMM's block device options would split, or whose name - /// ends in `.vhd`, `.vhdx`, `.vmgs`, or `.iso`, which OpenVMM would not attach as a raw - /// image, and an upper on a scratch image without the image's binding, or one in RAM with a - /// binding, [`Evidence::InvalidSettings`]; + /// whose image is not the format and identity that the binding names, as for a binding whose + /// identity is not a UUID in its canonical text, [`Evidence::InvalidFormat`]; an image or a + /// scratch image whose path is relative, which OpenVMM would resolve against the directory in + /// which it runs, holds a comma or a semicolon, which OpenVMM's block device options would + /// split, or whose name ends in `.vhd`, `.vhdx`, `.vmgs`, or `.iso`, which OpenVMM would not + /// attach as a raw image, and an upper on a scratch image without the image's binding, or one + /// in RAM with a binding, [`Evidence::InvalidSettings`]; /// - with [`ErrorKind::Other`] for a lease of another store, and a lock that is not the one /// that acquired the lease, before the launch that it reserves began, /// [`Evidence::HostFailure`]; @@ -342,7 +349,8 @@ impl ClonePlan { /// guest's resolver in another form than OpenVMM writes: more than once, without a NIC, or /// other than as one IP address in its canonical spelling, [`Evidence::MalformedGeneration`]; /// - with [`ErrorKind::Unsupported`] for settings that no activation record holds, as - /// [`Configuration::check`] finds them, such as a hostname that is empty, longer than + /// [`Configuration::check`] finds them, such as a GPT disk's first-partition bound of 0, a + /// hostname that is empty, longer than /// [`MAX_HOSTNAME_BYTES`](crate::activation::MAX_HOSTNAME_BYTES), or holds a NUL, and a /// workload identity whose UID or GID is 0, [`Evidence::InvalidActivate`], and with /// [`ErrorKind::Other`] if the transaction ID of the clone's restore cannot be drawn, the @@ -557,16 +565,18 @@ impl ClonePlan { /// Ready reports. /// /// The record holds the restore section, with the restore's transaction ID, 16 random bytes - /// that the plan drew, and the template that the [lease](Lease::template) names; the image, - /// an EROFS layer, with the identity of its binding and the UUID that the guest expects of its - /// filesystem, if the adapter's plan names one; the upper, a tmpfs, or the scratch image, with - /// the identity of its binding and the request's journal policy; the request's overlay mode - /// and workload view; no export; the network of the template's NIC, if it has one, with the - /// identity of the policy that OpenVMM enforces there, as the template's key records it, the - /// DNS server with which the template's guest configured its resolver at boot, if the - /// template's [command line](TemplateKey::command_line) names one, unpublished listeners, and - /// the URL of the policy's proxy as the guest sees it, if any; and the template's processors, - /// all online, and the memory target, as the resources that the clone must have. + /// that the plan drew, and the template that the [lease](Lease::template) names; the image, in + /// the format that its binding names, with the identity of its binding: an EROFS layer, with + /// the UUID that the guest expects of its filesystem, or a GPT disk, with the GUID that the + /// guest expects of the disk, each if the adapter's plan names one, and the request's bound of + /// the disk's first partition; the upper, a tmpfs, or the scratch image, with the identity of + /// its binding and the request's journal policy; the request's overlay mode and workload view; + /// no export; the network of the template's NIC, if it has one, with the identity of the + /// policy that OpenVMM enforces there, as the template's key records it, the DNS server with + /// which the template's guest configured its resolver at boot, if the template's + /// [command line](TemplateKey::command_line) names one, unpublished listeners, and the URL of + /// the policy's proxy as the guest sees it, if any; and the template's processors, all + /// online, and the memory target, as the resources that the clone must have. /// /// The identity of a binding is the SHA-256 digest of a domain of its kind and of its serde /// form, a few hundred bytes of metadata: the plan reads no content to compute it. @@ -635,9 +645,6 @@ fn check_request(request: &CloneRequest) -> Result<(), Fault> { )), )); } - if matches!(binding.format(), DistroFormat::Gpt) { - return Err(Fault::new(Evidence::InvalidFormat, record::gpt_refused())); - } check_disk_path(distro.path(), "image").attribute(Evidence::InvalidSettings)?; match (adapter.upper().scratch_path(), &request.scratch) { (Some(path), Some(_)) => { diff --git a/nvx-rs/src/host/template/clone/failure.rs b/nvx-rs/src/host/template/clone/failure.rs index 3b3af078e..99a46ecd0 100644 --- a/nvx-rs/src/host/template/clone/failure.rs +++ b/nvx-rs/src/host/template/clone/failure.rs @@ -82,8 +82,7 @@ pub enum Evidence { /// have, as the guest's /// [`ACTIVATION_RESOURCES`](crate::protocol::status::ACTIVATION_RESOURCES) tells. InvalidTargets, - /// The request's image is of a format that no clone restores, a GPT disk, or is not of the - /// format and identity that its binding names. + /// The request's image is not of the format and identity that its binding names. InvalidFormat, /// The request's settings are invalid: a plan that no clone takes, a path that OpenVMM would /// not open as checked, a network that the template's NIC does not match, an image or a diff --git a/nvx-rs/src/host/template/clone/record.rs b/nvx-rs/src/host/template/clone/record.rs index 9f04ab631..9883b9b85 100644 --- a/nvx-rs/src/host/template/clone/record.rs +++ b/nvx-rs/src/host/template/clone/record.rs @@ -38,8 +38,10 @@ const SCRATCH_DOMAIN: &[u8] = b"nvx scratch binding\0"; /// /// - the restore section: a transaction ID of 16 random bytes, and the template that `lease` /// names, its generation and the identity of its key; -/// - the image: an EROFS layer, the identity of its binding, and the UUID of its filesystem, -/// which the adapter's plan names, as the binding does; +/// - the image, in the format that its binding names: an EROFS layer, with the identity of its +/// binding and the UUID of its filesystem, or a GPT disk, with the identity of its binding, the +/// GUID of the disk, and the request's bound of its first partition; the adapter's plan names +/// the UUID or the GUID, as the binding does; /// - the upper: a tmpfs, or the scratch, with the identity of its binding and the request's /// journal policy; /// - the request's overlay mode and workload view; @@ -53,9 +55,9 @@ const SCRATCH_DOMAIN: &[u8] = b"nvx scratch binding\0"; /// - the expected resources: the template's processors, all of which a template's VM keeps /// online, and the request's memory target. /// -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a request whose image -/// is not an EROFS layer, [`Evidence::InvalidFormat`], and for a configuration that no record -/// holds, as [`Configuration::check`] finds it, such as a hostname that is empty or longer than +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a configuration that +/// no record holds, as [`Configuration::check`] finds it, such as a GPT disk's first-partition +/// bound of 0, a hostname that is empty or longer than /// [`MAX_HOSTNAME_BYTES`](crate::activation::MAX_HOSTNAME_BYTES), or a workload identity whose /// UID or GID is 0, [`Evidence::InvalidActivate`]; with [`ErrorKind::Io`](crate::ErrorKind::Io) /// for a key whose command line names its DNS server in a form that OpenVMM does not write, as @@ -67,12 +69,18 @@ pub(super) fn build( lease: &Lease, key: &TemplateKey, ) -> std::result::Result { + let binding = distro_identity(&request.binding).on_host()?; + let expected = request.adapter.distro().image().identity; let image = match request.binding.format() { DistroFormat::Erofs => Image::Erofs { - binding: distro_identity(&request.binding).on_host()?, - uuid: request.adapter.distro().image().identity, + binding, + uuid: expected, + }, + DistroFormat::Gpt => Image::Gpt { + binding, + disk_guid: expected, + first_partition_bound: request.first_partition_bound, }, - DistroFormat::Gpt => return Err(Fault::new(Evidence::InvalidFormat, gpt_refused())), }; let upper = match &request.scratch { None => Upper::Tmpfs, @@ -118,14 +126,6 @@ pub(super) fn build( }) } -/// Returns the error of a request whose image is a GPT disk, which no clone restores yet. -pub(super) fn gpt_refused() -> Error { - Error::unsupported( - "the image of a clone must be an EROFS layer: this build restores no clone of a GPT \ - image", - ) -} - /// Returns the identity of `binding`, the binding of a clone's image, as the [module](self) /// describes it. pub(super) fn distro_identity(binding: &DistroBinding) -> Result { diff --git a/nvx-rs/src/host/template/clone/tests.rs b/nvx-rs/src/host/template/clone/tests.rs index fc9bb98a4..306b7d542 100644 --- a/nvx-rs/src/host/template/clone/tests.rs +++ b/nvx-rs/src/host/template/clone/tests.rs @@ -46,6 +46,9 @@ const SCRATCH_LEN: u64 = 2 * MIB; const MEMORY: u64 = 512 * MIB; /// The template's RAM capacity, in bytes, as [`ManifestSpec::template`] records it. const MEMORY_CAPACITY: u64 = 2048 * MIB; +/// The bound of the first partition of the tests' GPT disks, which the record of a clone of an +/// EROFS layer leaves out. +const FIRST_PARTITION_BOUND: u64 = 64 * 1024; /// Nanoseconds in a second. const SECOND: u64 = 1_000_000_000; @@ -182,10 +185,15 @@ fn layer(path: &Path) -> SandboxLayer { SandboxLayer::new(LayerRole::Distro, path.to_str().unwrap(), UUID) } +/// Returns the distro GPT disk at `path`, whose disk GUID is [`UUID`]. +fn gpt_layer(path: &Path) -> SandboxLayer { + SandboxLayer::gpt(LayerRole::Distro, path.to_str().unwrap(), UUID) +} + /// Returns the request of a clone of the image at `image`, which `binding` binds, over an upper in /// RAM, with the template's RAM as its memory target, and the settings of [`workload`], for /// untrusted lowers, and with a journal policy, [`Journal::Forbidden`], that its record leaves -/// out. +/// out, and for a GPT disk, the bound [`FIRST_PARTITION_BOUND`]. fn tmpfs_request(image: &Path, binding: &DistroBinding) -> CloneRequest { CloneRequest { adapter: SandboxPlan::adapter( @@ -199,6 +207,7 @@ fn tmpfs_request(image: &Path, binding: &DistroBinding) -> CloneRequest { workload: workload(), overlay: OverlayMode::UntrustedLowers, journal: Journal::Forbidden, + first_partition_bound: FIRST_PARTITION_BOUND, } } @@ -371,6 +380,15 @@ impl Fixture { (path, binding) } + /// Stages the GPT disk `name` of `length` bytes, whose disk GUID is [`UUID`], as + /// [`image`](Self::image) stages an EROFS layer, and returns its path and its binding. + fn gpt_image(&self, name: &str, length: u64) -> (PathBuf, DistroBinding) { + let path = self.path("images", name); + stage(&path, length); + let binding = DistroBinding::import(&gpt_layer(&path), PolicyVerdict::ACCEPTED).unwrap(); + (path, binding) + } + /// Stages the scratch image `name` of `length` bytes, and returns its path and its binding. fn scratch(&self, name: &str, length: u64) -> (PathBuf, ScratchBinding) { let path = self.path("scratches", name); @@ -1434,6 +1452,25 @@ fn images_of_another_length_or_geometry_are_refused() { ) ); } + // A GPT disk has the placeholder's length too. + let (gpt, gpt_binding) = fixture.gpt_image("other.gpt", IMAGE_LEN + 512); + let plan = fixture.plan( + &mut acquired, + &tmpfs_request(&gpt, &gpt_binding), + NO_CONTENT, + ); + assert_eq!( + refused(plan, Evidence::SuppliedLength), + ( + ErrorKind::Unsupported, + format!( + "the image {} is {} bytes long, not the {IMAGE_LEN} bytes of the template's \ + placeholder", + shown(&gpt), + IMAGE_LEN + 512 + ) + ) + ); assert_untouched(&acquired); // A template whose placeholder presented physical blocks of another size serves no image of @@ -1744,6 +1781,39 @@ fn requests_that_no_clone_takes_are_refused_before_anything_is_opened() { shown(&image) ) ); + // An image of another format than the one that the binding names, either way. + let identity = Some(Uuid::parse(UUID).unwrap()); + let as_gpt = CloneRequest { + adapter: SandboxPlan::adapter( + SandboxDistro::new(image.to_str().unwrap(), RootImage::gpt(identity)), + SandboxUpper::RAM, + ), + ..tmpfs.clone() + }; + assert_eq!( + refusal(&as_gpt, Evidence::InvalidFormat), + format!( + "the image {} is not the erofs layer with identity {UUID} that its distro binding \ + names", + shown(&image) + ) + ); + let (gpt, gpt_binding) = fixture.gpt_image("distro.gpt", IMAGE_LEN); + let as_erofs = CloneRequest { + adapter: SandboxPlan::adapter( + SandboxDistro::new(gpt.to_str().unwrap(), RootImage::erofs(identity)), + SandboxUpper::RAM, + ), + binding: gpt_binding, + ..tmpfs.clone() + }; + assert_eq!( + refusal(&as_erofs, Evidence::InvalidFormat), + format!( + "the image {} is not the gpt layer with identity {UUID} that its distro binding names", + shown(&gpt) + ) + ); // Paths that OpenVMM would not open as the plan checks them. let at = |path: &str| CloneRequest { adapter: SandboxPlan::adapter(SandboxDistro::new(path, distro.image()), SandboxUpper::RAM), @@ -2121,6 +2191,47 @@ fn the_activation_record_of_a_clone_on_a_scratch_image_names_its_binding_and_jou ); } +#[test] +fn the_activation_record_of_a_gpt_clone_names_its_disk_and_the_bound_of_its_first_partition() { + let fixture = Fixture::new(); + let (image, binding) = fixture.gpt_image("distro.gpt", IMAGE_LEN); + let (scratch, scratch_binding) = fixture.scratch("scratch.ext4", SCRATCH_LEN); + let mut acquired = fixture.acquire("sandbox"); + let gpt = |first_partition_bound| Image::Gpt { + binding: digest_of(b"nvx distro binding\0", &binding), + disk_guid: Some(Uuid::parse(UUID).unwrap()), + first_partition_bound, + }; + // A GPT disk of the placeholder's geometry serves over RAM and over a scratch image, as an + // EROFS layer does, and its plan reads none of its content: the guest reads and checks the + // disk's partition table as it assembles the root. + for request in [ + tmpfs_request(&image, &binding), + scratch_request(&image, &binding, &scratch, &scratch_binding), + ] { + let plan = fixture.plan(&mut acquired, &request, NO_CONTENT).unwrap(); + assert_eq!(plan.image().path().to_str().unwrap(), shown(&image)); + assert_eq!( + plan.image().geometry().unwrap(), + geometry(IMAGE_LEN, fixture.physical) + ); + let mut byte = [0]; + assert!(plan.image().file().read(&mut byte).is_err()); + let configuration = plan.activation().configuration(); + assert_eq!(configuration.image, gpt(FIRST_PARTITION_BOUND)); + assert_eq!(configuration.workload, workload()); + } + // The record keeps the request's bound, whatever it is from 1 on. + for bound in [1, u64::MAX] { + let request = CloneRequest { + first_partition_bound: bound, + ..tmpfs_request(&image, &binding) + }; + let plan = fixture.plan(&mut acquired, &request, NO_CONTENT).unwrap(); + assert_eq!(plan.activation().configuration().image, gpt(bound)); + } +} + #[test] fn the_activation_record_of_a_clone_of_a_template_with_a_nic_names_its_policy_and_dns_server() { let fixture = Fixture::with(|class, spec, physical| { @@ -2247,27 +2358,28 @@ fn settings_that_no_activation_record_holds_are_refused_before_anything_is_creat ); assert_untouched(&acquired); } - // A GPT image, whose binding names it, is refused before anything is opened. - let path = fixture.path("images", "distro.gpt"); - stage(&path, IMAGE_LEN); - let gpt = SandboxLayer::gpt(LayerRole::Distro, path.to_str().unwrap(), UUID); - let gpt_binding = DistroBinding::import(&gpt, PolicyVerdict::ACCEPTED).unwrap(); - let gpt_request = CloneRequest { - adapter: SandboxPlan::adapter( - SandboxDistro::new(path.to_str().unwrap(), bound_image(&gpt_binding).unwrap()), - SandboxUpper::RAM, - ), - binding: gpt_binding, - ..request.clone() + // A GPT disk's first-partition bound of 0, which an EROFS layer's record leaves out. + let (path, gpt_binding) = fixture.gpt_image("distro.gpt", IMAGE_LEN); + let unbounded = CloneRequest { + first_partition_bound: 0, + ..tmpfs_request(&path, &gpt_binding) }; - let plan = fixture.plan(&mut acquired, &gpt_request, NO_CONTENT); - let (kind, message) = refused(plan, Evidence::InvalidFormat); - assert_eq!(kind, ErrorKind::Unsupported, "{message}"); - assert!( - message.starts_with("the image of a clone must be an EROFS layer"), - "{message}" + let plan = fixture.plan(&mut acquired, &unbounded, NO_CONTENT); + assert_eq!( + refused(plan, Evidence::InvalidActivate), + ( + ErrorKind::Unsupported, + "no clone activates with the settings of the request: the bound of a GPT disk's first \ + partition is 0" + .to_owned() + ) ); assert_untouched(&acquired); + let erofs = CloneRequest { + first_partition_bound: 0, + ..request.clone() + }; + drop(fixture.plan(&mut acquired, &erofs, NO_CONTENT).unwrap()); drop(fixture.plan(&mut acquired, &request, NO_CONTENT).unwrap()); } diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index 5c2c3a3c6..b7660d83f 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -1361,16 +1361,20 @@ of the class's latest capture, when the class's slot keeps one. `runtime-adapter-host start` starts a clone of a generation of a template store in a sandbox store, through NVX's public API, `nvx::host::template`. Its library side, `clone`, holds what the command's options choose of the clone, -`clone::Request`: its image, an EROFS layer of the length of the template's -placeholder, its upper, on an ext4 scratch image or in RAM, its memory target, -the template's RAM unless given, the workload identity as which its executions -run, its overlay mode, and its scratch's journal policy. `Request::plan` plans -the image over the upper as a launch plans a layered root, and -`Request::clone_request` returns NVX's `CloneRequest` of it, with the bindings -that the sandbox's store stages for the images, as `stage` stages them, so the -start hashes nothing. The clone's activation record names the image's own -machine ID, the hostname `runtime-adapter`, and the guest's defaults of the -other settings, unless the request names them. +`clone::Request`: its image, an EROFS layer or a GPT disk of the length of the +template's placeholder, its upper, on an ext4 scratch image or in RAM, its +memory target, the template's RAM unless given, the workload identity as which +its executions run, its overlay mode, and its scratch's journal policy. +`Request::plan` plans the image over the upper as a launch plans a layered +root, and `Request::clone_request` returns NVX's `CloneRequest` of it, with the +bindings that the sandbox's store stages for the images, as `stage` stages +them, so the start hashes nothing. The clone's activation record names the +image's own machine ID, the hostname `runtime-adapter`, and the guest's +defaults of the other settings, unless the request names them; for a GPT disk, +it bounds the first partition by 64 KiB, as a launch's guest does by default. +The guest of a GPT clone rereads the disk's partition table before it +assembles the root, since its kernel created the partitions of the template's +placeholder. The command first reads the generation from the store's index, and, unless the request names the clone's memory target, its compatibility key, whose RAM the @@ -1566,7 +1570,9 @@ the directory that `NVX_ARTIFACTS_DIR` names, and the guest agent's build from starts a [clone](#clones) of the generation G of the template store TEMPLATES in the sandbox store STORE, which it creates if needed, and whose `stage` staged its images, which no one else may change: the image PATH, an - EROFS file system, `erofs`, of which the guest expects the identity UUID, + EROFS file system, `erofs`, or a GPT disk, `gpt`, of which the guest expects + the identity UUID, the file system's UUID or the disk's GUID, of the length of + the template's placeholder, whatever the placeholder's format, over the ext4 scratch image PATH, or RAM, with a memory target of MIB mebibytes, the template's RAM by default, the workload identity UID:GID, if given, as which every execution runs, and whose account the guest checks before the clone is ready, and the overlay diff --git a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs index 73addc7e2..f18261952 100644 --- a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs +++ b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs @@ -107,10 +107,11 @@ //! the sandbox, at the host's time, with `Store::acquire`, which refuses a generation that a //! tombstone quarantines, or that is not eligible then, and NVX's `ClonePlan` checks the clone //! against the generation, from the metadata of its files alone: its image, the raw file PATH, -//! an EROFS filesystem, `erofs`, of which the guest expects the identity UUID, of the length of -//! the template's placeholder, its upper, the ext4 scratch image PATH, of the geometry of the -//! template's scratch, or RAM, with the bindings that STORE stages for the images, as `stage` -//! stages them, and its memory target, in whole blocks, from the template's RAM to its capacity. +//! an EROFS filesystem, `erofs`, or a GPT disk, `gpt`, of which the guest expects the identity +//! UUID, of the length of the template's placeholder, whatever the placeholder's format, its +//! upper, the ext4 scratch image PATH, of the geometry of the template's scratch, or RAM, with +//! the bindings that STORE stages for the images, as `stage` stages them, and its memory target, +//! in whole blocks, from the template's RAM to its capacity. //! A request that the template cannot serve fails there, before any VMM starts. The command //! records the clone in STORE, with the template store and the generation; see //! `runtime_adapter::clone::CloneRecord`. OpenVMM then restores the generation's snapshot in diff --git a/tests/runtime-adapter/src/clone.rs b/tests/runtime-adapter/src/clone.rs index 6773f9885..23909df6f 100644 --- a/tests/runtime-adapter/src/clone.rs +++ b/tests/runtime-adapter/src/clone.rs @@ -2,10 +2,10 @@ //! NVX's template store leases to a sandbox, as `runtime-adapter-host start` asks. //! //! A [`Request`] holds what the host program's options choose of a clone: its image, an EROFS -//! layer of the length of the template's placeholder, its upper, on an ext4 scratch image or in -//! RAM, its memory target, the workload identity as which its executions run, its overlay mode, -//! and its scratch's journal policy. [`Request::plan`] plans the image over the upper as a launch -//! plans a layered root, and [`Request::clone_request`] returns NVX's +//! layer or a GPT disk of the length of the template's placeholder, its upper, on an ext4 scratch +//! image or in RAM, its memory target, the workload identity as which its executions run, its +//! overlay mode, and its scratch's journal policy. [`Request::plan`] plans the image over the +//! upper as a launch plans a layered root, and [`Request::clone_request`] returns NVX's //! `nvx::host::template::CloneRequest` of it, with the bindings that the sandbox's store stages //! for its images, as `staged::Staged` records them: staging hashes, and a start never does. //! NVX's `ClonePlan` then checks the request against the leased generation before any VMM starts, @@ -39,7 +39,7 @@ use nvx::{Error, Result}; use serde_json::{Value, json}; use crate::layered::{Bindings, RECORD_FORMAT, RECORD_NAME, plan, unstaged, write_record}; -use crate::root::{JournalPolicy, OverlayMode}; +use crate::root::{DEFAULT_FIRST_PARTITION_BOUND, JournalPolicy, OverlayMode}; use crate::staged::invalid; /// The mode of a clone's [record](CloneRecord). @@ -103,7 +103,8 @@ impl Request { /// The record of the clone's activation names the workload identity, if any, the /// [`CLONE_HOSTNAME`], the image's own machine ID, the overlay's mode, and the scratch's /// journal policy, each the guest's default unless the request names it, as a root - /// configuration's are. + /// configuration's are, and for a GPT disk, the bound of its first partition that a launch's + /// guest takes by default, [`DEFAULT_FIRST_PARTITION_BOUND`]. /// /// Fails with [`ErrorKind::Unavailable`](nvx::ErrorKind::Unavailable) if the store stages no /// binding for the image, or for the scratch image of an upper on one, at its path. @@ -149,6 +150,7 @@ impl Request { }, overlay, journal, + first_partition_bound: DEFAULT_FIRST_PARTITION_BOUND, }) } } From 5102663a6d28777dc867b417a0a3ff44f04afb4c Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sun, 25 Oct 2026 18:15:36 -0700 Subject: [PATCH 3/3] runtime-adapter: start GPT clones, and refuse a used upper, in VMs A clone's guest rereads a GPT disk's partition table before it assembles the root, and refuses an upper that is not fresh, and the host now plans GPT clones. These VM tests start such clones through runtime-adapter-host start, on the hypervisor. start takes --first-partition-bound BYTES for a GPT disk, as launch does, and clone::Request takes the bound, 64 KiB unless given. In vm_clone, one template, whose placeholder is a GPT disk of two layers, serves four clones at once: the EROFS image over a scratch image and in RAM, a GPT disk of one layer in RAM, and one of three layers over a scratch image, with a bound that its first partition meets exactly. The disks' layer partitions start where none of the placeholder's do, nor each other's, so a clone whose kernel kept the placeholder's partitions would fail its assembly. Each GPT clone's root stacks the layers of its own disk's table, read-only, and returns its own first partition. A disk whose first partition is one byte larger than the bound ends the clone with status 54, as does a scratch image that an earlier clone used, whose upper is not fresh. The GPT writer moves from vm_layered into tests/common/gpt.rs, with the small ext2 layers and the CRC, and writes disks of any layout and length. vm_layered writes the same disks as before through it. Part of #845 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 80932e09-af82-4b79-b417-70a241303cda --- tests/runtime-adapter/README.md | 58 ++- .../src/bin/runtime-adapter-host.rs | 52 ++- tests/runtime-adapter/src/clone.rs | 24 +- tests/runtime-adapter/tests/clone_start.rs | 17 +- tests/runtime-adapter/tests/common/gpt.rs | 382 ++++++++++++++++ tests/runtime-adapter/tests/common/mod.rs | 2 + tests/runtime-adapter/tests/vm_clone.rs | 419 +++++++++++++++--- tests/runtime-adapter/tests/vm_layered.rs | 337 ++------------ 8 files changed, 880 insertions(+), 411 deletions(-) create mode 100644 tests/runtime-adapter/tests/common/gpt.rs diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index b7660d83f..62e7dfe99 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -1364,14 +1364,15 @@ library side, `clone`, holds what the command's options choose of the clone, `clone::Request`: its image, an EROFS layer or a GPT disk of the length of the template's placeholder, its upper, on an ext4 scratch image or in RAM, its memory target, the template's RAM unless given, the workload identity as which -its executions run, its overlay mode, and its scratch's journal policy. -`Request::plan` plans the image over the upper as a launch plans a layered -root, and `Request::clone_request` returns NVX's `CloneRequest` of it, with the -bindings that the sandbox's store stages for the images, as `stage` stages -them, so the start hashes nothing. The clone's activation record names the -image's own machine ID, the hostname `runtime-adapter`, and the guest's -defaults of the other settings, unless the request names them; for a GPT disk, -it bounds the first partition by 64 KiB, as a launch's guest does by default. +its executions run, its overlay mode, its scratch's journal policy, and the +bound of a GPT disk's first partition. `Request::plan` plans the image over the +upper as a launch plans a layered root, and `Request::clone_request` returns +NVX's `CloneRequest` of it, with the bindings that the sandbox's store stages +for the images, as `stage` stages them, so the start hashes nothing. The +clone's activation record names the image's own machine ID, the hostname +`runtime-adapter`, and the guest's defaults of the other settings, unless the +request names them; for a GPT disk, the bound of its first partition is 64 KiB +unless the request names one, as a launch's guest takes it by default. The guest of a GPT clone rereads the disk's partition table before it assembles the root, since its kernel created the partitions of the template's placeholder. @@ -1566,17 +1567,19 @@ the directory that `NVX_ARTIFACTS_DIR` names, and the guest agent's build from its record again, and releases the lease of a clone that ended, through the template store TEMPLATES, if given, in place of the one that the clone's record names. -- `start STORE --template TEMPLATES --generation G --distro FORMAT:PATH --distro-identity UUID --upper (scratch:PATH | ram) [--memory MIB] [--workload-identity UID:GID] [--overlay-mode MODE] [--scratch-journal POLICY]` +- `start STORE --template TEMPLATES --generation G --distro FORMAT:PATH --distro-identity UUID --upper (scratch:PATH | ram) [--memory MIB] [--workload-identity UID:GID] [--overlay-mode MODE] [--scratch-journal POLICY] [--first-partition-bound BYTES]` starts a [clone](#clones) of the generation G of the template store TEMPLATES in the sandbox store STORE, which it creates if needed, and whose `stage` staged its images, which no one else may change: the image PATH, an EROFS file system, `erofs`, or a GPT disk, `gpt`, of which the guest expects - the identity UUID, the file system's UUID or the disk's GUID, of the length of - the template's placeholder, whatever the placeholder's format, - over the ext4 scratch image PATH, or RAM, with a memory target of MIB mebibytes, the template's RAM by default, - the workload identity UID:GID, if given, as which every execution runs, and - whose account the guest checks before the clone is ready, and the overlay - mode MODE and the scratch's journal policy POLICY, as a launch takes them. + the identity UUID, the file system's UUID or the disk's GUID, of the length + of the template's placeholder, whatever the placeholder's format, over the + ext4 scratch image PATH, or RAM, with a memory target of MIB mebibytes, the + template's RAM by default, the workload identity UID:GID, if given, as which + every execution runs, and whose account the guest checks before the clone is + ready, the overlay mode MODE and the scratch's journal policy POLICY, as a + launch takes them, and for a GPT disk, the bound BYTES of its first + partition, 64 KiB by default, as a launch's guest takes it. Once the restore sequence started the clone's sandbox, it exchanges a `Ping` and the longest `Echo` with the guest agent, commits the launch, and exits, leaving the VM running, and the sandbox holding the lease. A clone that @@ -2045,9 +2048,10 @@ Windows, without a hypervisor: - the command lines of `start` that the host program refuses, each with status 2: missing options, a generation that is not a positive number, a scratch's - journal policy for an upper in RAM, invalid values, and the options of other - commands, and the options of `start` that other commands do not take, with a - clock fixture that is not a host's time too; + journal policy for an upper in RAM, a first-partition bound for an EROFS + image, invalid values, and the options of other commands, and the options of + `start` that other commands do not take, with a clock fixture that is not a + host's time too; - a start whose template store does not exist, or does not hold the generation, and whose image the plan refuses, each with status 1 before it needs NVX's artifacts or creates the sandbox's store; @@ -2350,9 +2354,10 @@ MBR, and the primary and backup tables of 128 entries, with the first partition, 4 KiB of bytes that are not UTF-8, then padding; the base layer, in partition 2, a copy of the template; and the top layer, in partition 3, a small file system in the ext2 format, which the kernel's ext4 driver mounts, with a -file and a directory. The test writes the top layer itself, since `mkfs.ext4` -is not available everywhere that the tests run, and checks the disk with -`nvx::gpt` before it launches the VM. For `corrupt_layer_fails_the_assembly`, +file and a directory. The test writes the disk, and the top layer itself, with +`tests/common/gpt.rs`, since `mkfs.ext4` is not available everywhere that the +tests run, and checks the disk with `nvx::gpt` before it launches the VM. For +`corrupt_layer_fails_the_assembly`, it corrupts the top layer: the inode of the layer's top directory loses its mode, an error that the kernel finds when it mounts the layer, and the error policy of its superblock asks the kernel to panic on an error, which the @@ -2406,14 +2411,21 @@ in the host-only configuration, through `runtime-adapter-host start`. Each test captures a template of its own, of 1 processor and 256 MiB of RAM with a capacity of 1 GiB, in a template store of its own, of a copy of NVX's Ubuntu EROFS image that a directory of the platform holds, which the clones stage as -their image, and starts its clones, each in a sandbox store of its own, over a -copy of the scratch template, which no one else may change either, or in RAM: +their image, or of a GPT disk of its length that the platform holds beside it, +and starts its clones, each in a sandbox store of its own, over a copy of the +scratch template, which no one else may change either, or in RAM. The GPT disks +of a template's placeholder and of the clones, which the test writes with +`tests/common/gpt.rs`, as `tests/vm_layered.rs` writes its own, hold their +first partition, then empty layers, at partitions that differ from one disk to +another: | Test | Clones | Expects | | --- | --- | --- | | `one_generation_serves_clones_of_each_upper_and_memory_target` | Two at once, over a scratch image at 256 MiB, and in RAM at 512 MiB with untrusted lowers, then one over a scratch image at 1 GiB | Each started, by the host's monotonic clock, with its own lease, which the store lists with the clone's sandbox, its memory target online, its upper, overlay mode, and image's contents, and the events of its activation in order; a stop releases each lease; the generation's snapshot unchanged, and its payload with nothing but the capture's files once the clones stopped | +| `one_generation_serves_clones_of_either_format_over_either_upper` | Of a template whose placeholder is a GPT disk of two layers, four at once: the EROFS image over a scratch image and in RAM, a GPT disk of one layer in RAM, and one of three layers over a scratch image, with a bound that its first partition meets exactly; then the disk of one layer, with a bound one byte smaller than its first partition | Each started, with its upper and the overlay's options; the EROFS image's root, without a first partition; each GPT disk's layers, ext4 file systems mounted read-only once each, from the partitions that its own table names, stacked in order, and the bytes of its own first partition; then status 8, and the record of a failure of the request, at the activation, once the VM exited, by the evidence of an unusable root, with the guest's status 54; nothing quarantined, and the lease released, with the records | | `a_start_refuses_what_the_template_cannot_serve_before_any_vmm_starts` | An image 4 KiB longer, one 4 KiB shorter, and one 100 bytes longer, than the placeholder, a scratch image of twice the template's scratch, memory targets of 2 GiB and of 320 MiB, unstaged images, and a generation that the store does not hold, then the generation once its key is gone | For those that NVX's plan refuses, status 8, with NVX's reason, and the record of a failure of the request, at the plan, without a VM, by the evidence of a length that the request supplied, of the image's geometry, which OpenVMM cannot attach, or of invalid targets, which quarantines nothing; for the others, status 1, without a record; any lease released, and no VMM's log or record in the sandbox; for the generation without its key, status 8, and the record of a failure of the template, at the plan, by the evidence of a malformed generation, which quarantines the generation, so that a later start fails without a lease | | `a_clone_whose_activation_fails_ends_before_it_is_ready` | A scratch image of zeros, a journal that the record forbids, a workload identity whose account the image lacks, and, through NVX's restore sequence, a ping, a start, or the start of an execution before the `Activate` | Status 8, no `Ready`, and the record of a failure, at the activation, once the VM exited: of the request, by the evidence of an unusable root, with the guest's status 54, or of invalid settings, with 55, or, before the `Activate`, of an unknown cause, by the protocol's, with 51, which the sandbox records until the release; nothing quarantined, and the lease released, with the records | +| `a_clone_refuses_a_scratch_image_whose_upper_an_earlier_clone_used` | Over a scratch image, in whose root the `contents` probe writes a file, then over a copy of that scratch image once the first clone stopped | The first started; then status 8, since the copy's upper is not fresh, and the record of a failure of the request, at the activation, once the VM exited, by the evidence of an unusable root, with the guest's status 54; nothing quarantined, and the lease released, with the records | | `a_clone_runs_its_executions_as_its_workload_identity_once_it_is_ready` | Over a scratch image, as nobody | An execution that runs as nobody, one that names an identity refused with `EINVAL`, and the first execution after the clone's start | | `a_generation_is_eligible_by_the_clock_of_its_capture` | At the host's time, then with clock fixtures: UTC a day behind, another boot an hour after the capture by UTC, and the restore window passed by each clock, or UTC before the capture | The host's monotonic clock on the boot of the capture, whatever UTC says, UTC after a reboot, with the fixture's downtime, and the store's refusal without a lease outside the window | | `a_clone_reads_no_more_of_its_disks_than_its_root_needs` | Over a scratch image, and in RAM | Less than an eighth of each disk read once the clone is ready, and each disk's whole length once the guest read it to its end | diff --git a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs index f18261952..48f9bd81d 100644 --- a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs +++ b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs @@ -97,21 +97,22 @@ //! the session's next epoch, and exchanges a `Ping`, leaving the VM running. //! - `runtime-adapter-host start STORE --template TEMPLATES --generation G --distro FORMAT:PATH //! --distro-identity UUID --upper (scratch:PATH | ram) [--memory MIB] [--workload-identity -//! UID:GID] [--overlay-mode MODE] [--scratch-journal POLICY]` starts a clone of the generation G -//! of the template store TEMPLATES, an `nvx::host::template::Store`, in the sandbox store STORE, -//! which it creates if needed; see `runtime_adapter::clone`. It reads the generation from the -//! store's index, and, unless MIB mebibytes are given, its compatibility key, whose RAM is then -//! the clone's memory target, before it needs NVX's artifacts. A key that cannot be read is left -//! to NVX's plan, which reads it again, and attributes the failure, to the generation or to the -//! host, as it does for every clone. The store then leases the generation to -//! the sandbox, at the host's time, with `Store::acquire`, which refuses a generation that a -//! tombstone quarantines, or that is not eligible then, and NVX's `ClonePlan` checks the clone -//! against the generation, from the metadata of its files alone: its image, the raw file PATH, -//! an EROFS filesystem, `erofs`, or a GPT disk, `gpt`, of which the guest expects the identity -//! UUID, of the length of the template's placeholder, whatever the placeholder's format, its -//! upper, the ext4 scratch image PATH, of the geometry of the template's scratch, or RAM, with -//! the bindings that STORE stages for the images, as `stage` stages them, and its memory target, -//! in whole blocks, from the template's RAM to its capacity. +//! UID:GID] [--overlay-mode MODE] [--scratch-journal POLICY] [--first-partition-bound BYTES]` +//! starts a clone of the generation G of the template store TEMPLATES, an +//! `nvx::host::template::Store`, in the sandbox store STORE, which it creates if needed; see +//! `runtime_adapter::clone`. It reads the generation from the store's index, and, unless MIB +//! mebibytes are given, its compatibility key, whose RAM is then the clone's memory target, +//! before it needs NVX's artifacts. A key that cannot be read is left to NVX's plan, which reads +//! it again, and attributes the failure, to the generation or to the host, as it does for every +//! clone. The store then leases the generation to the sandbox, at the host's time, with +//! `Store::acquire`, which refuses a generation that a tombstone quarantines, or that is not +//! eligible then, and NVX's `ClonePlan` checks the clone against the generation, from the +//! metadata of its files alone: its image, the raw file PATH, an EROFS filesystem, `erofs`, or a +//! GPT disk, `gpt`, of which the guest expects the identity UUID, of the length of the template's +//! placeholder, whatever the placeholder's format, its upper, the ext4 scratch image PATH, of the +//! geometry of the template's scratch, or RAM, with the bindings that STORE stages for the +//! images, as `stage` stages them, and its memory target, in whole blocks, from the template's +//! RAM to its capacity. //! A request that the template cannot serve fails there, before any VMM starts. The command //! records the clone in STORE, with the template store and the generation; see //! `runtime_adapter::clone::CloneRecord`. OpenVMM then restores the generation's snapshot in @@ -120,10 +121,14 @@ //! adapter's clone order, `runtime_adapter::host::CloneOrder`, with the clone's activation //! record: the workload identity UID:GID, if given, as which every execution runs, whose account //! the guest checks in the clone's root before it is ready, and the overlay mode MODE and the -//! scratch's journal policy POLICY, as a launch takes them. Once the clone is ready, the command -//! starts its sandbox, exchanges a `Ping` and the longest `Echo` with the guest agent, commits -//! the launch, and exits, leaving the VM running, and the sandbox holding the lease, which a stop -//! releases. A clone that fails, from its plan to its commit, fails with NVX's +//! scratch's journal policy POLICY, as a launch takes them, and for a GPT disk, the bound BYTES +//! of its first partition, 64 KiB unless given, as a launch's guest takes it by default. The +//! guest refuses a first partition larger than BYTES, and an upper that is not fresh, whose +//! `upper` or `work` directory on the scratch image exists and is not empty, as an earlier +//! clone's would be, before the clone is ready. Once the clone is ready, the command starts its +//! sandbox, exchanges a `Ping` and the longest `Echo` with the guest agent, commits the launch, +//! and exits, leaving the VM running, and the sandbox holding the lease, which a stop releases. +//! A clone that fails, from its plan to its commit, fails with NVX's //! `nvx::host::template::CloneFailure`, which attributes the failure by its evidence, and records //! it in STORE from the clone's launch on: the command prints the failure's record, releases the //! lease once the failure's teardown is resolved, and nothing of the clone runs, or keeps it, and @@ -410,6 +415,7 @@ const USAGE: &str = concat!( " [--memory MIB]\n", " [--workload-identity UID:GID] [--overlay-mode MODE]", " [--scratch-journal POLICY]\n", + " [--first-partition-bound BYTES]\n", " runtime-adapter-host probe STORE PROBE... [--guest-build BUILD]\n", " runtime-adapter-host exec STORE [--guest-build BUILD] EXECUTION [';' EXECUTION]...\n", " EXECUTION: [--identity UID:GID [--groups GID,...] [--capabilities N,...]", @@ -920,6 +926,9 @@ fn parse(arguments: Vec) -> Result { if scratch.is_none() && options.scratch_journal.is_some() { return Err("--scratch-journal needs --upper scratch:PATH".to_owned()); } + if format == ImageFormat::Erofs && options.first_partition_bound.is_some() { + return Err("--first-partition-bound needs --distro gpt:PATH".to_owned()); + } Command::Start { store, guest_build, @@ -935,6 +944,7 @@ fn parse(arguments: Vec) -> Result { workload_identity: options.workload_identity, overlay_mode: options.overlay_mode, scratch_journal: options.scratch_journal, + first_partition_bound: options.first_partition_bound, }, }), time: host_time_from_env()?, @@ -1025,7 +1035,9 @@ fn parse_option( })?; options.scratch_journal = Some(policy); } - ("launch", "--first-partition-bound") if options.first_partition_bound.is_none() => { + ("launch" | "start", "--first-partition-bound") + if options.first_partition_bound.is_none() => + { let value = text(value)?; let bound = value.parse::().ok().filter(|bound| *bound > 0); options.first_partition_bound = Some(bound.ok_or_else(|| { diff --git a/tests/runtime-adapter/src/clone.rs b/tests/runtime-adapter/src/clone.rs index 23909df6f..53546e568 100644 --- a/tests/runtime-adapter/src/clone.rs +++ b/tests/runtime-adapter/src/clone.rs @@ -4,12 +4,12 @@ //! A [`Request`] holds what the host program's options choose of a clone: its image, an EROFS //! layer or a GPT disk of the length of the template's placeholder, its upper, on an ext4 scratch //! image or in RAM, its memory target, the workload identity as which its executions run, its -//! overlay mode, and its scratch's journal policy. [`Request::plan`] plans the image over the -//! upper as a launch plans a layered root, and [`Request::clone_request`] returns NVX's -//! `nvx::host::template::CloneRequest` of it, with the bindings that the sandbox's store stages -//! for its images, as `staged::Staged` records them: staging hashes, and a start never does. -//! NVX's `ClonePlan` then checks the request against the leased generation before any VMM starts, -//! from the metadata of the clone's files alone. +//! overlay mode, its scratch's journal policy, and the bound of a GPT disk's first partition. +//! [`Request::plan`] plans the image over the upper as a launch plans a layered root, and +//! [`Request::clone_request`] returns NVX's `nvx::host::template::CloneRequest` of it, with the +//! bindings that the sandbox's store stages for its images, as `staged::Staged` records them: +//! staging hashes, and a start never does. NVX's `ClonePlan` then checks the request against the +//! leased generation before any VMM starts, from the metadata of the clone's files alone. //! //! A clone's [record](CloneRecord) names the template store and the generation that the clone //! leases, beside the plan and the bindings, as the record of a launch names what it launched, so @@ -77,6 +77,9 @@ pub struct Request { /// Whether the scratch may have a journal, or `None` for the guest's default, which allows /// one. pub scratch_journal: Option, + /// The most bytes that the first partition of a GPT disk may hold, or `None` for + /// [`DEFAULT_FIRST_PARTITION_BOUND`], the default of a launch's guest. + pub first_partition_bound: Option, } impl Request { @@ -103,8 +106,9 @@ impl Request { /// The record of the clone's activation names the workload identity, if any, the /// [`CLONE_HOSTNAME`], the image's own machine ID, the overlay's mode, and the scratch's /// journal policy, each the guest's default unless the request names it, as a root - /// configuration's are, and for a GPT disk, the bound of its first partition that a launch's - /// guest takes by default, [`DEFAULT_FIRST_PARTITION_BOUND`]. + /// configuration's are, and the bound of a GPT disk's first partition, the default of a + /// launch's guest, [`DEFAULT_FIRST_PARTITION_BOUND`], unless the request names one. The record + /// of a clone of an EROFS layer leaves the bound out. /// /// Fails with [`ErrorKind::Unavailable`](nvx::ErrorKind::Unavailable) if the store stages no /// binding for the image, or for the scratch image of an upper on one, at its path. @@ -150,7 +154,9 @@ impl Request { }, overlay, journal, - first_partition_bound: DEFAULT_FIRST_PARTITION_BOUND, + first_partition_bound: self + .first_partition_bound + .unwrap_or(DEFAULT_FIRST_PARTITION_BOUND), }) } } diff --git a/tests/runtime-adapter/tests/clone_start.rs b/tests/runtime-adapter/tests/clone_start.rs index b8dd9a78e..8a7bcc2a2 100644 --- a/tests/runtime-adapter/tests/clone_start.rs +++ b/tests/runtime-adapter/tests/clone_start.rs @@ -20,6 +20,7 @@ use runtime_adapter::clone::{ CLONE_MODE, CloneRecord, HOST_TIME_VARIABLE, Request, host_time_text, parse_host_time, }; use runtime_adapter::layered::{Bindings, LaunchRecord, RECORD_FORMAT, RECORD_NAME}; +use runtime_adapter::root::DEFAULT_FIRST_PARTITION_BOUND; use runtime_adapter::staged::Staged; use serde_json::{Value, json}; @@ -124,6 +125,14 @@ fn the_host_program_refuses_options_that_do_not_make_a_start() { with(&["--upper", "ram", "--overlay-mode", "lowers"]), "--overlay-mode takes trusted-lowers or untrusted-lowers", ), + ( + with(&["--upper", "ram", "--first-partition-bound", "4096"]), + "--first-partition-bound needs --distro gpt:PATH", + ), + ( + with(&["--upper", "ram", "--first-partition-bound", "0"]), + "--first-partition-bound takes a positive number of bytes, not \"0\"", + ), ( with(&["--upper", "ram", "--template", "other"]), "unexpected option \"--template\" of start", @@ -154,11 +163,10 @@ fn the_host_program_refuses_options_that_do_not_make_a_start() { ), ]; // Each option that a start does not take. - let others: [(&str, &str); 8] = [ + let others: [(&str, &str); 7] = [ ("--guest-agent", "agent"), ("--hypervisor", "whp"), ("--runtime-dir", "/run/a"), - ("--first-partition-bound", "4096"), ("--share", "a:/b"), ("--network", "network.json"), ("--cpu-profile", "auto"), @@ -341,6 +349,7 @@ fn a_clone_s_record_names_its_template_store_and_generation_beside_its_plan() { workload_identity: Some((1000, 1000)), overlay_mode: None, scratch_journal: None, + first_partition_bound: None, }; let plan = request.plan().unwrap(); let staged = Staged::stage(&distro, ImageFormat::Erofs, identity(), Some(&scratch)).unwrap(); @@ -428,6 +437,7 @@ fn a_clone_s_request_takes_the_staged_bindings_and_the_guest_s_defaults() { workload_identity: None, overlay_mode: None, scratch_journal: None, + first_partition_bound: None, }; let plan = request.plan().unwrap(); let staged = Staged::stage(&distro, ImageFormat::Erofs, identity(), Some(&scratch)).unwrap(); @@ -452,12 +462,14 @@ fn a_clone_s_request_takes_the_staged_bindings_and_the_guest_s_defaults() { assert_eq!(clone.workload.identity, None); assert_eq!(clone.overlay, nvx::activation::OverlayMode::TrustedLowers); assert_eq!(clone.journal, nvx::activation::Journal::Allowed); + assert_eq!(clone.first_partition_bound, DEFAULT_FIRST_PARTITION_BOUND); // A request that names its settings. let named = Request { workload_identity: Some((65534, 65533)), overlay_mode: Some(runtime_adapter::root::OverlayMode::UntrustedLowers), scratch_journal: Some(runtime_adapter::root::JournalPolicy::Forbidden), + first_partition_bound: Some(4096), ..request.clone() }; let clone = named @@ -472,6 +484,7 @@ fn a_clone_s_request_takes_the_staged_bindings_and_the_guest_s_defaults() { ); assert_eq!(clone.overlay, nvx::activation::OverlayMode::UntrustedLowers); assert_eq!(clone.journal, nvx::activation::Journal::Forbidden); + assert_eq!(clone.first_partition_bound, 4096); // An image, or a scratch image, that the store does not stage. for (bindings, what) in [ diff --git a/tests/runtime-adapter/tests/common/gpt.rs b/tests/runtime-adapter/tests/common/gpt.rs new file mode 100644 index 000000000..58e112ba5 --- /dev/null +++ b/tests/runtime-adapter/tests/common/gpt.rs @@ -0,0 +1,382 @@ +//! The GPT disks of the VM tests' layered roots, which [`write_disk`] writes as UEFI lays out a +//! disk, and the small layers that they hold, file systems in the ext2 format that +//! [`small_layer`] writes, which the kernel's ext4 driver mounts, since `mkfs.ext4` is not +//! available everywhere that the tests run. + +use std::fs::{File, OpenOptions}; +use std::io::{self, Seek, SeekFrom, Write}; +use std::path::Path; + +use nvx::gpt::{Disk, Expected, ExpectedPartition}; +use nvx::uuid::Uuid; + +/// The size of a sector of a disk, its logical block, in bytes. +const SECTOR: u64 = 512; +/// The first sector that a partition may take: after the protective MBR, the primary header, and +/// its array of 128 entries of 128 bytes. +const FIRST_USABLE: u64 = 34; +/// The type GUID of every partition of the disks: Linux's file system data. +const LINUX_DATA: &str = "0fc63daf-8483-4772-8e79-3d69d8477de4"; +/// The size of a block of the layer that [`small_layer`] writes, in bytes. +pub(crate) const LAYER_BLOCK: usize = 1024; +/// The number of blocks of that layer: 128 KiB. +pub(crate) const LAYER_BLOCKS: usize = 128; +/// The number of inodes of that layer, of which the first ten are reserved, as in every ext2 file +/// system, and the second is its top directory. +const LAYER_INODES: usize = 16; + +/// The layout of a GPT disk that [`write_disk`] writes. +pub(crate) struct Layout<'a> { + /// The disk's GUID. + pub(crate) guid: &'a str, + /// The partitions, in the order of their entries: the first partition, then the layers. + pub(crate) partitions: &'a [Partition<'a>], + /// The number of sectors of which the first sector of each partition is a multiple: at least + /// [`FIRST_USABLE`]. + pub(crate) alignment: u64, + /// The disk's length, in bytes, whole sectors, or `None` for the partitions' and then + /// `alignment` sectors more, in which the backup array and header end the disk. + pub(crate) length: Option, +} + +/// A partition of a [`Layout`]. +pub(crate) struct Partition<'a> { + /// The partition's unique GUID. + pub(crate) guid: &'a str, + /// The partition's size, in bytes: whole sectors, at least what it holds. + pub(crate) size: u64, + /// What the partition holds from its start, before NUL bytes. + pub(crate) contents: Contents<'a>, +} + +/// What a [`Partition`] holds. +pub(crate) enum Contents<'a> { + /// These bytes. + Bytes(&'a [u8]), + /// The bytes of this file. + File(&'a Path), +} + +/// Writes the GPT disk of `layout` at `path`, as UEFI lays out a disk, with a protective MBR, the +/// primary GPT header and its array of 128 entries, and the backup array and header at the disk's +/// end, of its partitions in their order, each aligned to its `alignment`, after the end of the +/// one before. The file is a new one, which no one else may change: of the mode 0644 on Unix, +/// whatever the umask, and on Windows of its directory's access control list. +/// +/// Then checks the disk as the guest's assembly does, through `nvx::gpt`, with the GUIDs and +/// sizes that it wrote, and returns the bytes of its first partition, as the guest returns them, +/// without their trailing NUL bytes and whitespace. +pub(crate) fn write_disk(path: &Path, layout: &Layout<'_>) -> Vec { + let alignment = layout.alignment; + assert!( + alignment >= FIRST_USABLE, + "a partition starts after the table" + ); + let mut extents = Vec::new(); + let mut next = alignment; + for partition in layout.partitions { + assert_eq!(partition.size % SECTOR, 0, "a partition is whole sectors"); + let last = next + partition.size / SECTOR - 1; + extents.push((next, last)); + next = (last + 1).next_multiple_of(alignment); + } + // The backup array's 32 sectors and the backup header end the disk, after the usable sectors. + let sectors = match layout.length { + None => next + alignment, + Some(length) => { + assert_eq!(length % SECTOR, 0, "a disk is whole sectors"); + length / SECTOR + } + }; + let (first_usable, last_usable) = (FIRST_USABLE, sectors - 34); + assert!( + extents.last().is_some_and(|(_, last)| *last <= last_usable), + "the partitions end before the backup array" + ); + let (backup_entries, backup_header) = (sectors - 33, sectors - 1); + let guid = |text: &str| Uuid::parse(text).unwrap().to_bytes_le(); + + let mut entries = vec![0; 128 * 128]; + for (entry, ((first, last), partition)) in entries + .chunks_exact_mut(128) + .zip(extents.iter().zip(layout.partitions)) + { + entry[0..16].copy_from_slice(&guid(LINUX_DATA)); + entry[16..32].copy_from_slice(&guid(partition.guid)); + entry[32..40].copy_from_slice(&first.to_le_bytes()); + entry[40..48].copy_from_slice(&last.to_le_bytes()); + } + let entries_crc = crc32(&entries); + let header = |own: u64, alternate: u64, array: u64| { + let mut header = vec![0; 92]; + header[0..8].copy_from_slice(b"EFI PART"); + header[8..12].copy_from_slice(&0x0001_0000_u32.to_le_bytes()); + header[12..16].copy_from_slice(&92_u32.to_le_bytes()); + header[24..32].copy_from_slice(&own.to_le_bytes()); + header[32..40].copy_from_slice(&alternate.to_le_bytes()); + header[40..48].copy_from_slice(&first_usable.to_le_bytes()); + header[48..56].copy_from_slice(&last_usable.to_le_bytes()); + header[56..72].copy_from_slice(&guid(layout.guid)); + header[72..80].copy_from_slice(&array.to_le_bytes()); + header[80..84].copy_from_slice(&128_u32.to_le_bytes()); + header[84..88].copy_from_slice(&128_u32.to_le_bytes()); + header[88..92].copy_from_slice(&entries_crc.to_le_bytes()); + let crc = crc32(&header); + header[16..20].copy_from_slice(&crc.to_le_bytes()); + header + }; + // The protective MBR: one record of type 0xEE, from LBA 1 over the rest of the disk, with the + // addresses that UEFI gives it. + let mut mbr = vec![0; 512]; + let protective = u32::try_from(sectors - 1).unwrap_or(u32::MAX); + mbr[446..450].copy_from_slice(&[0x00, 0x00, 0x02, 0x00]); + mbr[450..454].copy_from_slice(&[0xee, 0xff, 0xff, 0xff]); + mbr[454..458].copy_from_slice(&1_u32.to_le_bytes()); + mbr[458..462].copy_from_slice(&protective.to_le_bytes()); + mbr[510..512].copy_from_slice(&[0x55, 0xaa]); + + let mut disk = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(path) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + disk.set_permissions(std::fs::Permissions::from_mode(0o644)) + .unwrap(); + } + disk.set_len(sectors * SECTOR).unwrap(); + let table = [ + (0, mbr), + (1, header(1, backup_header, 2)), + (2, entries.clone()), + (backup_entries, entries), + (backup_header, header(backup_header, 1, backup_entries)), + ]; + for (sector, bytes) in table { + disk.seek(SeekFrom::Start(sector * SECTOR)).unwrap(); + disk.write_all(&bytes).unwrap(); + } + for ((first, _), partition) in extents.iter().zip(layout.partitions) { + disk.seek(SeekFrom::Start(first * SECTOR)).unwrap(); + let written = match partition.contents { + Contents::Bytes(bytes) => { + disk.write_all(bytes).unwrap(); + u64::try_from(bytes.len()).unwrap() + } + Contents::File(file) => io::copy(&mut File::open(file).unwrap(), &mut disk).unwrap(), + }; + assert!(written <= partition.size, "a partition holds what it holds"); + } + disk.sync_all().unwrap(); + + let partitions = layout + .partitions + .iter() + .map(|partition| { + ExpectedPartition::new() + .unique_guid(Uuid::parse(partition.guid).unwrap()) + .size(partition.size) + }) + .collect(); + let expected = Expected::new() + .disk_guid(Uuid::parse(layout.guid).unwrap()) + .partitions(partitions); + let read = Disk::read(&disk, 512, sectors * SECTOR, &expected).unwrap(); + let size = layout.partitions[0].size; + read.read_first_partition(&disk, usize::try_from(size).unwrap()) + .unwrap() +} + +/// Returns a small file system in the ext2 format, of [`LAYER_BLOCKS`] blocks of [`LAYER_BLOCK`] +/// bytes, which the kernel's ext4 driver mounts as an ext4 file system without features: its top +/// directory holds the regular `files`, each a name and its bytes, of at most 12 blocks each, +/// then the empty `directories`, each with mode 0755, and at most [`LAYER_INODES`] inodes in all. +/// +/// Block 1 holds the superblock, 2 the group descriptor, 3 and 4 the block and inode bitmaps, 5 +/// and 6 the inode table, 7 the top directory, and the blocks after them the files' bytes and the +/// directories. +pub(crate) fn small_layer(files: &[(&str, &[u8])], directories: &[&str]) -> Vec { + /// Writes `value` at `offset` of `image`, in little-endian order. + fn put(image: &mut [u8], offset: usize, value: [u8; N]) { + image[offset..offset + N].copy_from_slice(&value); + } + /// Writes the inode `number` of `image`, of `mode`, `size` bytes, and `links` links, whose data + /// is in `blocks`, at most the 12 that an inode addresses directly. + fn inode( + image: &mut [u8], + number: usize, + mode: u16, + size: usize, + links: usize, + blocks: &[usize], + ) { + assert!( + blocks.len() <= 12, + "an inode of the layer addresses 12 blocks" + ); + let offset = 5 * LAYER_BLOCK + (number - 1) * 128; + put(image, offset, mode.to_le_bytes()); + put( + image, + offset + 4, + u32::try_from(size).unwrap().to_le_bytes(), + ); + put( + image, + offset + 26, + u16::try_from(links).unwrap().to_le_bytes(), + ); + // The number of 512-byte sectors that the data takes. + let sectors = u32::try_from(blocks.len() * LAYER_BLOCK / 512).unwrap(); + put(image, offset + 28, sectors.to_le_bytes()); + for (index, block) in blocks.iter().enumerate() { + let block = u32::try_from(*block).unwrap(); + put(image, offset + 40 + 4 * index, block.to_le_bytes()); + } + } + /// Writes the directory `entries` into `block` of `image`, each an inode, a name, and a file + /// type, the last of which spans the rest of the block. + fn directory(image: &mut [u8], block: usize, entries: &[(usize, &str, u8)]) { + let (mut offset, end) = (block * LAYER_BLOCK, (block + 1) * LAYER_BLOCK); + for (index, (inode, name, kind)) in entries.iter().enumerate() { + let length = if index + 1 == entries.len() { + end - offset + } else { + (8 + name.len()).next_multiple_of(4) + }; + put(image, offset, u32::try_from(*inode).unwrap().to_le_bytes()); + put( + image, + offset + 4, + u16::try_from(length).unwrap().to_le_bytes(), + ); + image[offset + 6] = u8::try_from(name.len()).unwrap(); + image[offset + 7] = *kind; + image[offset + 8..offset + 8 + name.len()].copy_from_slice(name.as_bytes()); + offset += length; + } + } + /// Sets the bits `bits` of the bitmap in `block` of `image`. + fn set(image: &mut [u8], block: usize, bits: std::ops::Range) { + for bit in bits { + image[block * LAYER_BLOCK + bit / 8] |= 1 << (bit % 8); + } + } + + let (top, regular, directory_type) = (2, 1, 2); + let mut image = vec![0; LAYER_BLOCK * LAYER_BLOCKS]; + // The first block and the first inode that are free. + let (mut block, mut number) = (8, 11); + let mut entries = vec![(top, ".", directory_type), (top, "..", directory_type)]; + for (name, bytes) in files { + let blocks = (block..block + bytes.len().div_ceil(LAYER_BLOCK)).collect::>(); + image[block * LAYER_BLOCK..block * LAYER_BLOCK + bytes.len()].copy_from_slice(bytes); + inode(&mut image, number, 0o100_644, bytes.len(), 1, &blocks); + entries.push((number, *name, regular)); + block += blocks.len(); + number += 1; + } + for name in directories { + directory( + &mut image, + block, + &[(number, ".", directory_type), (top, "..", directory_type)], + ); + inode(&mut image, number, 0o040_755, LAYER_BLOCK, 2, &[block]); + entries.push((number, *name, directory_type)); + block += 1; + number += 1; + } + directory(&mut image, 7, &entries); + inode( + &mut image, + top, + 0o040_755, + LAYER_BLOCK, + 2 + directories.len(), + &[7], + ); + assert!( + block <= LAYER_BLOCKS && number <= LAYER_INODES + 1, + "the layer is too small" + ); + + // The bitmaps of the used blocks, from block 1, and inodes, from inode 1, whose bits past the + // last block and inode are set, as `mke2fs` sets them. + set(&mut image, 3, 0..block - 1); + set(&mut image, 3, LAYER_BLOCKS - 1..LAYER_BLOCK * 8); + set(&mut image, 4, 0..number - 1); + set(&mut image, 4, LAYER_INODES..LAYER_BLOCK * 8); + let free_blocks = LAYER_BLOCKS - block; + let free_inodes = LAYER_INODES + 1 - number; + + // The superblock: one group of blocks, inodes of 128 bytes, the first of them not reserved, + // 11, and only the `filetype` feature. + let superblock = LAYER_BLOCK; + for (offset, value) in [ + (0x00, LAYER_INODES), + (0x04, LAYER_BLOCKS), + (0x0c, free_blocks), + (0x10, free_inodes), + (0x14, 1), + (0x20, LAYER_BLOCK * 8), + (0x24, LAYER_BLOCK * 8), + (0x28, LAYER_INODES), + (0x4c, 1), + (0x54, 11), + (0x60, 0x2), + ] { + let value = u32::try_from(value).unwrap(); + put(&mut image, superblock + offset, value.to_le_bytes()); + } + for (offset, value) in [ + (0x36, 0xffff), + (0x38, 0xef53), + (0x3a, 1), + (0x3c, 1), + (0x58, 128), + ] { + put(&mut image, superblock + offset, u16::to_le_bytes(value)); + } + let uuid = Uuid::parse("2d4e6f80-91a2-4b3c-8d5e-6f708192a3b4").unwrap(); + image[superblock + 0x68..superblock + 0x78].copy_from_slice(uuid.as_bytes()); + + // The group descriptor: where the bitmaps and the inode table are, and what is free. + let descriptor = 2 * LAYER_BLOCK; + for (offset, value) in [(0x00, 3), (0x04, 4), (0x08, 5)] { + put(&mut image, descriptor + offset, u32::to_le_bytes(value)); + } + for (offset, value) in [ + (0x0c, free_blocks), + (0x0e, free_inodes), + (0x10, 1 + directories.len()), + ] { + let value = u16::try_from(value).unwrap(); + put(&mut image, descriptor + offset, value.to_le_bytes()); + } + image +} + +/// Returns the CRC-32 of `bytes`, which GPT's headers and entry arrays hold: of the reflected +/// polynomial 0xEDB88320, from all ones, inverted. +fn crc32(bytes: &[u8]) -> u32 { + !bytes.iter().fold(!0, |crc, byte| { + (0..8).fold(crc ^ u32::from(*byte), |crc: u32, _| { + (crc >> 1) ^ (0xedb8_8320 & (crc & 1).wrapping_neg()) + }) + }) +} + +/// Returns `bytes` in lowercase hexadecimal, as the `first-partition` probe reports them. +pub(crate) fn hexadecimal(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + bytes + .iter() + .flat_map(|byte| [byte >> 4, byte & 0xf]) + .map(|digit| char::from(DIGITS[usize::from(digit)])) + .collect() +} diff --git a/tests/runtime-adapter/tests/common/mod.rs b/tests/runtime-adapter/tests/common/mod.rs index 061dd6c97..67604ddff 100644 --- a/tests/runtime-adapter/tests/common/mod.rs +++ b/tests/runtime-adapter/tests/common/mod.rs @@ -12,6 +12,8 @@ // Each test crate uses its own part of this module. #![allow(dead_code)] +pub(crate) mod gpt; + use std::collections::BTreeMap; use std::convert::Infallible; use std::env; diff --git a/tests/runtime-adapter/tests/vm_clone.rs b/tests/runtime-adapter/tests/vm_clone.rs index f6cbfe897..9529ef106 100644 --- a/tests/runtime-adapter/tests/vm_clone.rs +++ b/tests/runtime-adapter/tests/vm_clone.rs @@ -5,15 +5,22 @@ //! //! Each scenario captures a [`Template`] of its own, with 256 MiB of RAM, of a capacity of //! 1 GiB, in a template store of its own, as `vm_template` does, of the platform's copy of the -//! fixtures' Ubuntu EROFS image, and starts its clones, each in a sandbox store of its own, which -//! stages the clone's images, as a launch's does: that copy, and for a clone over a scratch image, -//! a copy of the scratch template, which, as NVX requires of a clone's images, no one else may -//! change either. +//! fixtures' Ubuntu EROFS image, or of a GPT [`Disk`] of the image's length, and starts its +//! clones, each in a sandbox store of its own, which stages the clone's images, as a launch's +//! does: that copy, or a GPT disk, and for a clone over a scratch image, a copy of the scratch +//! template, which, as NVX requires of a clone's images, no one else may change either. //! //! - One generation serves clones over a scratch image and in RAM, at once and in turn, at each //! memory target from the template's RAM to its capacity, which the guest has online. The store //! leases the generation to each, a stop releases it, and the clones change nothing of the //! generation's snapshot, and leave nothing in its payload. +//! - One generation serves clones of either format over either upper, at once: the platform's +//! EROFS image, and GPT disks of other layouts than the template's placeholder, a GPT disk too, +//! and than each other's. The guest of a GPT clone rereads its disk's partition table before it +//! assembles the root, since its kernel created the partitions of the placeholder, so each +//! clone's root stacks its own disk's layers, from the partitions that its disk's table names. +//! A disk whose first partition is larger than the clone's bound ends the clone before it is +//! ready. //! - The store and NVX's plan refuse a clone that the template cannot serve before any VMM starts, //! each a failure of the request, as NVX attributes it by its evidence, with no VM: an image or a //! scratch image of another length than the template's, an image of a geometry that OpenVMM @@ -26,6 +33,8 @@ //! file system, a journal that the clone's record forbids, a workload identity whose account the //! image lacks, and a host that sends anything but the clone's Activate first, a ping, a start, or //! an execution. The sandbox records the failure until the release of its lease. +//! - A clone refuses a scratch image whose upper is not fresh, as an earlier clone left it, and +//! ends before it is ready. //! - A clone runs its executions as its record's workload identity, once it is ready: the repair //! acknowledged the restore before the clone took its reset, the host's first message came //! after that, and the clone's Ready, its start, and its first execution came in that order. @@ -98,9 +107,11 @@ use runtime_adapter::exec::{HostMessage, Start}; use runtime_adapter::frame::Message; use runtime_adapter::hook::{Action, STEP_HOOK, Step, StepHook}; use runtime_adapter::host::Client; +use runtime_adapter::root::DEFAULT_RUNTIME; use runtime_adapter::runtime::Runtime; use runtime_adapter::staged::Staged; +use common::gpt::{self, Contents, Layout, Partition, hexadecimal, small_layer}; use common::{Fixtures, Printed, Sandbox}; /// The exit status of the host program's command that failed, after a diagnostic. @@ -131,15 +142,106 @@ const HOUR_NS: u64 = 3_600_000_000_000; /// The error number with which the guest refuses an execution that names an identity in a /// sandbox that has its own: `EINVAL`. const EINVAL: i32 = 22; +/// The placeholder of the template of the scenarios of GPT disks: a disk of two layers, whose +/// partitions start at multiples of 1 MiB. +const PLACEHOLDER: Disk = Disk { + guid: "5c0f2a8e-3b71-4d96-8e04-7a1c9b2d6e30", + partitions: &[ + "5c0f2a8e-3b71-4d96-8e04-7a1c9b2d6e31", + "5c0f2a8e-3b71-4d96-8e04-7a1c9b2d6e32", + "5c0f2a8e-3b71-4d96-8e04-7a1c9b2d6e33", + ], + alignment: 2048, + first_size: 4096, + first: b"\xffthe placeholder's first partition", +}; +/// A clone's GPT disk of one layer, whose partitions start at multiples of 2 MiB, with a larger +/// first partition than the placeholder's: its layer's partition starts where none of the +/// placeholder's does. +const ONE_LAYER: Disk = Disk { + guid: "a17c3e90-6d24-4f5b-b8e1-02c9d4f6a8b0", + partitions: &[ + "a17c3e90-6d24-4f5b-b8e1-02c9d4f6a8b1", + "a17c3e90-6d24-4f5b-b8e1-02c9d4f6a8b2", + ], + alignment: 4096, + first_size: 8192, + first: b"\xfe\0one layer\x80", +}; +/// A clone's GPT disk of three layers, whose partitions start at multiples of 640 KiB, with the +/// largest first partition: its layers' partitions start where none of the placeholder's do, nor +/// [`ONE_LAYER`]'s. +const THREE_LAYERS: Disk = Disk { + guid: "b2e4f6a8-0c1d-4e3f-9a5b-6c7d8e9f0a10", + partitions: &[ + "b2e4f6a8-0c1d-4e3f-9a5b-6c7d8e9f0a11", + "b2e4f6a8-0c1d-4e3f-9a5b-6c7d8e9f0a12", + "b2e4f6a8-0c1d-4e3f-9a5b-6c7d8e9f0a13", + "b2e4f6a8-0c1d-4e3f-9a5b-6c7d8e9f0a14", + ], + alignment: 1280, + first_size: 12288, + first: b"\xfd\tthree layers\xc3", +}; + +/// A GPT disk of the scenarios, of the length of the platform's image, which +/// [`Template::of_disk`] captures as the template's placeholder, or [`Template::disk`] writes for +/// a clone. Its partitions follow each other, each at a multiple of its alignment: its first +/// partition, then a layer, an empty [`small_layer`], in each of the others. +struct Disk { + /// The disk's GUID, which the guest expects of it. + guid: &'static str, + /// The unique GUIDs of its partitions: the first partition's, then each layer's, from the base + /// up. + partitions: &'static [&'static str], + /// The number of sectors of which the first sector of each partition is a multiple. + alignment: u64, + /// The size of its first partition, in bytes. + first_size: u64, + /// The bytes that its first partition holds, before NUL bytes: those that the guest returns, + /// which end in neither a NUL byte nor whitespace. + first: &'static [u8], +} + +impl Disk { + /// Writes the disk at `path`, of `length` bytes, a file that no one else may change. + fn write(&self, path: &Path, length: u64) { + let layer = small_layer(&[], &[]); + let partitions = self + .partitions + .iter() + .enumerate() + .map(|(index, guid)| { + let (size, contents) = match index { + 0 => (self.first_size, self.first), + _ => (u64::try_from(layer.len()).unwrap(), layer.as_slice()), + }; + Partition { + guid, + size, + contents: Contents::Bytes(contents), + } + }) + .collect::>(); + let layout = Layout { + guid: self.guid, + partitions: &partitions, + alignment: self.alignment, + length: Some(length), + }; + assert_eq!(gpt::write_disk(path, &layout), self.first); + } +} /// The template of a scenario: the generation that a template store of its own captured, in the -/// scenario's sandbox, of the platform's copy of the fixtures' Ubuntu EROFS image, with -/// [`TEMPLATE_MEMORY`] MiB of RAM, of a capacity of [`TEMPLATE_CAPACITY`] MiB. +/// scenario's sandbox, of the platform's copy of the fixtures' Ubuntu EROFS image, or of a GPT +/// [`Disk`] of its length, with [`TEMPLATE_MEMORY`] MiB of RAM, of a capacity of +/// [`TEMPLATE_CAPACITY`] MiB. struct Template { /// The scenario's sandbox, whose store is the template store, until the template drops. sandbox: Option, - /// The platform's copy of the image, the template's placeholder, which the clones stage as - /// their image too. + /// The platform's copy of the image, which the clones stage as their image unless they name + /// another, and the template's placeholder, unless that is a GPT disk. image: PathBuf, /// The image's identity. identity: String, @@ -149,22 +251,42 @@ struct Template { impl Template { /// Creates the sandbox of the scenario `name`, stages the platform's copy of the image, and - /// captures the template in the sandbox's store. + /// captures the template of the image in the sandbox's store. /// /// On Linux, a clone's control socket, `generations/1/LAUNCH.sock` in the template store, /// after the 32 hexadecimal digits of the launch, must fit the path of a Unix socket, so the /// scenarios' names are short. fn create(name: &str) -> Self { + Self::capture(name, None) + } + + /// Creates the sandbox of the scenario `name`, stages the platform's copy of the image, and + /// captures the template of the GPT disk `placeholder`, of the image's length, which the + /// platform stages beside it, as [`create`](Self::create) does. + fn of_disk(name: &str, placeholder: &Disk) -> Self { + Self::capture(name, Some(placeholder)) + } + + /// Creates the sandbox of the scenario `name`, stages the platform's copy of the image, and + /// captures the template of the GPT disk `placeholder`, if any, and of the image otherwise. + fn capture(name: &str, placeholder: Option<&Disk>) -> Self { let mut sandbox = Sandbox::new(name); let fixtures = Fixtures::find(); let image = stage_platform(&sandbox.root.join("platform"), &fixtures); - let distro = erofs(&image); + let (distro, identity) = match placeholder { + None => (erofs(&image), fixtures.identity.as_str()), + Some(disk) => { + let path = image.with_file_name("placeholder.gpt"); + disk.write(&path, fs::metadata(&image).unwrap().len()); + (prefixed("gpt:", &path), disk.guid) + } + }; let (memory, capacity) = (TEMPLATE_MEMORY.to_string(), TEMPLATE_CAPACITY.to_string()); let options = [ OsStr::new("--distro"), &distro, OsStr::new("--distro-identity"), - OsStr::new(&fixtures.identity), + OsStr::new(identity), OsStr::new("--memory"), OsStr::new(&memory), OsStr::new("--memory-capacity"), @@ -226,6 +348,14 @@ impl Template { copy } + /// Writes the GPT disk `disk`, of the length of the platform's image, which a clone restores + /// in place of the template's placeholder, beside the image, at `name`, and returns its path. + fn disk(&self, name: &str, disk: &Disk) -> PathBuf { + let path = self.image.with_file_name(name); + disk.write(&path, fs::metadata(&self.image).unwrap().len()); + path + } + /// Stages the template's image in the store of `sandbox`, which it creates, for a clone in /// RAM. fn stage(&self, sandbox: &mut Sandbox) { @@ -243,12 +373,15 @@ impl Template { /// start exits with `status`, and returns the clone. fn start(&self, name: &str, start: StartOf<'_>, status: i32) -> Cloned { let mut sandbox = Sandbox::new(name); - let distro = erofs(start.image.unwrap_or(&self.image)); + let (distro, identity) = match start.image { + None => (erofs(&self.image), self.identity.as_str()), + Some(Image::Erofs(path)) => (erofs(path), self.identity.as_str()), + Some(Image::Gpt(disk, path)) => (prefixed("gpt:", path), disk.guid), + }; // NVX restores a clone only over images that no one else may change, so the scratch image // lies in a directory of the sandbox's own, as the platform's image lies in the platform's. - let images = sandbox.root.join("images"); - SandboxStore::create(&images).unwrap(); - let scratch = images.join("scratch.ext4"); + let scratch = scratch_image(&sandbox); + SandboxStore::create(scratch.parent().unwrap()).unwrap(); match start.upper { Upper::Ram => {} Upper::Scratch => { @@ -261,12 +394,15 @@ impl Template { Upper::Zeros => private_file(&scratch) .set_len(DEFAULT_SCRATCH_LENGTH) .unwrap(), + Upper::Used(used) => { + private_copy(used, &scratch); + } } let upper = match start.upper { Upper::Ram => OsString::from("ram"), _ => prefixed("scratch:", &scratch), }; - let identity = OsStr::new(&self.identity); + let identity = OsStr::new(identity); if !start.unstaged { let mut staging = vec![ OsStr::new("--distro"), @@ -340,7 +476,7 @@ impl Drop for Template { /// The upper of a clone. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -enum Upper { +enum Upper<'a> { /// RAM. #[default] Ram, @@ -351,15 +487,27 @@ enum Upper { Resized(u64), /// Zeros of the template's scratch length, which hold no file system. Zeros, + /// A copy of the scratch image at this path, which an earlier clone used, so that its upper is + /// not fresh. + Used(&'a Path), +} + +/// A clone's image other than the platform's. +#[derive(Clone, Copy)] +enum Image<'a> { + /// An EROFS image at this path, a variant of the platform's, with its identity. + Erofs(&'a Path), + /// A GPT disk of this layout at this path, which [`Template::disk`] wrote. + Gpt(&'a Disk, &'a Path), } /// What a scenario asks of the start of a clone. #[derive(Default)] struct StartOf<'a> { - /// The image, or the template's image by default. - image: Option<&'a Path>, + /// The image, or the platform's image by default. + image: Option>, /// The upper. - upper: Upper, + upper: Upper<'a>, /// The start's other options. options: &'a [&'a str], /// The host's time at which the store checks the generation's eligibility, as the clock @@ -443,6 +591,48 @@ impl Cloned { let store = SandboxStore::open(&self.sandbox.store).unwrap(); assert!(Lease::of(&store).unwrap().is_none()); } + + /// Checks that the start started the clone's VM, which ended before the clone was ready, with + /// `status`, the guest's status of its activation's failure, and that the start printed the + /// failure's record, of the request, by `evidence`, at the activation, once the VM exited, + /// quarantined nothing, and released the lease of `template`'s generation, which no other + /// clone holds, with the records of the clone's launch. + fn assert_unactivated(&self, template: &Template, evidence: &str, status: u8) { + let printed = &self.started; + assert!(printed.contains_key("pid"), "{printed:?}"); + assert!(!printed.contains_key("root"), "{printed:?}"); + let status = status.to_string(); + assert_eq!( + failure(printed), + [ + "request", + evidence, + "activation", + "activating", + status.as_str(), + "exited" + ], + "{printed:?}" + ); + assert_eq!(printed["quarantined"], "no"); + assert_eq!(printed["released"], printed["lease"], "{printed:?}"); + assert!(self.sandbox.record().is_none()); + // The release removed the records of the clone's launch. + let store = SandboxStore::open(&self.sandbox.store).unwrap(); + assert!(CloneRecords::read(&store).unwrap().is_none()); + assert!(template.leases().is_empty()); + } + + /// Returns the clone's scratch image, for a clone over one. + fn scratch(&self) -> PathBuf { + scratch_image(&self.sandbox) + } +} + +/// Returns the scratch image of a clone over one, in the clone's sandbox, `sandbox`, in a +/// directory of the sandbox's own. +fn scratch_image(sandbox: &Sandbox) -> PathBuf { + sandbox.root.join("images").join("scratch.ext4") } /// Stages the platform's copy of the fixtures' Ubuntu EROFS image in the directory `platform`, @@ -558,11 +748,60 @@ fn assert_memory(facts: &Printed, target: u64) { assert!(number("total") <= target * MIB, "{facts:?}"); } -/// Checks the root of a clone over `upper`, as its `mounts` and `contents` probes found it, in -/// the overlay mode that trusts the lowers, or not. -fn assert_root(facts: &Printed, upper: Upper, trusted: bool) { +/// Checks the root of a clone of the platform's image over `upper`, as its `mounts` and `contents` +/// probes found it, in the overlay mode that trusts the lowers, or not. +fn assert_root(facts: &Printed, upper: Upper<'_>, trusted: bool) { assert_eq!(facts["mounts.distro.count"], "1", "{facts:?}"); assert_eq!(facts["mounts.distro.type"], "erofs", "{facts:?}"); + assert_overlay(facts, upper, trusted, "ubuntu"); +} + +/// Checks the root of a clone of the GPT disk `disk` over `upper`, as its `devices`, `mounts`, +/// `contents`, and `first-partition` probes found it, in the overlay mode that trusts the lowers: +/// each of the disk's layers, an ext4 file system, mounted once, read-only, from the partition of +/// the clone's one read-only disk that the disk's table names, which the overlay stacks, the base +/// last; and the bytes of the disk's first partition. +fn assert_gpt_root(facts: &Printed, disk: &Disk, upper: Upper<'_>) { + let read_only = facts["devices.disks"] + .split(' ') + .filter_map(|device| device.strip_suffix(":ro")) + .filter_map(|device| device.split(':').next()) + .collect::>(); + let &[name] = read_only.as_slice() else { + panic!("the clone has one read-only disk: {facts:?}"); + }; + let numbers = 2..=disk.partitions.len(); + for number in numbers.clone() { + let fact = |key: &str| facts[&format!("mounts.p{number}.{key}")].as_str(); + let source = format!("/dev/{name}{number}"); + assert_eq!( + [fact("count"), fact("type"), fact("source")], + ["1", "ext4", source.as_str()], + "{facts:?}" + ); + let options = fact("options").split(',').collect::>(); + assert!(options.contains(&"ro"), "{facts:?}"); + } + let lowers = numbers + .rev() + .map(|number| format!("{DEFAULT_RUNTIME}/layers/p{number}")) + .collect::>(); + let stacked = format!("lowerdir={}", lowers.join(":")); + let overlay = facts["mounts.root.super"].split(',').collect::>(); + assert!( + overlay.contains(&stacked.as_str()), + "{stacked} in {facts:?}" + ); + assert_overlay(facts, upper, true, "absent"); + let (length, bytes) = (disk.first.len().to_string(), hexadecimal(disk.first)); + assert_eq!(facts["first-partition.length"], length, "{facts:?}"); + assert_eq!(facts["first-partition.bytes"], bytes, "{facts:?}"); +} + +/// Checks the upper, the overlay, and the contents of the root of a clone over `upper`, as its +/// `mounts` and `contents` probes found them, in the overlay mode that trusts the lowers, or not, +/// whose `os-release` names `identifier`, or `absent` for a root without one. +fn assert_overlay(facts: &Printed, upper: Upper<'_>, trusted: bool, identifier: &str) { let (used, unused, kind) = match upper { Upper::Ram => ("tmpfs", "scratch", "tmpfs"), _ => ("scratch", "tmpfs", "ext4"), @@ -583,7 +822,7 @@ fn assert_root(facts: &Printed, upper: Upper, trusted: bool) { .map(|key| &facts[&format!("contents.{key}")]); assert_eq!( contents, - ["ubuntu", "runtime-adapter", "runtime-adapter", "refused"], + [identifier, "runtime-adapter", "runtime-adapter", "refused"], "{facts:?}" ); } @@ -713,6 +952,7 @@ fn refuses_early(template: &Template, early: Message) { workload_identity: None, overlay_mode: None, scratch_journal: None, + first_partition_bound: None, }; let plan = request.plan().unwrap(); let staged = Staged::read(&sandbox.store).unwrap().unwrap(); @@ -874,6 +1114,76 @@ fn one_generation_serves_clones_of_each_upper_and_memory_target() { assert_payload(&template, false); } +#[test] +#[ignore = "boots VMs; see the module documentation for what they need"] +fn one_generation_serves_clones_of_either_format_over_either_upper() { + let template = Template::of_disk("cl-gpt", &PLACEHOLDER); + let one = template.disk("one.gpt", &ONE_LAYER); + let three = template.disk("three.gpt", &THREE_LAYERS); + + // At once: the platform's image over a scratch image and in RAM, and two GPT disks, of other + // layouts than the placeholder's and than each other's, one in RAM, and one over a scratch + // image, with a bound that its first partition meets exactly. + let bound = THREE_LAYERS.first_size.to_string(); + let exact = ["--first-partition-bound", bound.as_str()]; + let starts = [ + ("cl-e", None, Upper::Scratch, &[][..]), + ("cl-f", None, Upper::Ram, &[][..]), + ( + "cl-g", + Some(Image::Gpt(&ONE_LAYER, &one)), + Upper::Ram, + &[][..], + ), + ( + "cl-h", + Some(Image::Gpt(&THREE_LAYERS, &three)), + Upper::Scratch, + &exact[..], + ), + ]; + let mut clones = starts.map(|(name, image, upper, options)| { + let start = StartOf { + image, + upper, + options, + ..StartOf::default() + }; + let cloned = template.start(name, start, 0); + cloned.assert_started(); + (image, upper, cloned) + }); + assert_eq!(template.leases().len(), clones.len()); + assert_payload(&template, true); + for (image, upper, cloned) in &mut clones { + let facts = cloned.probe(&["devices", "mounts", "contents", "first-partition"]); + match image { + Some(Image::Gpt(disk, _)) => assert_gpt_root(&facts, disk, *upper), + _ => { + assert_root(&facts, *upper, true); + assert_eq!(facts["first-partition.length"], "none", "{facts:?}"); + } + } + } + for (_, _, cloned) in &mut clones { + cloned.stop(); + } + assert!(template.leases().is_empty()); + + // A disk whose first partition is larger than the clone's bound ends the clone before it is + // ready. + let under = (ONE_LAYER.first_size - 1).to_string(); + let options = ["--first-partition-bound", under.as_str()]; + let start = StartOf { + image: Some(Image::Gpt(&ONE_LAYER, &one)), + options: &options, + ..StartOf::default() + }; + let ended = template.start("cl-g", start, CLONE_FAILED); + ended.assert_unactivated(&template, "unusable-root", status::ACTIVATION_ROOT); + assert_payload(&template, false); +} + #[test] #[ignore = "boots VMs; see the module documentation for what they need"] fn a_start_refuses_what_the_template_cannot_serve_before_any_vmm_starts() { @@ -892,7 +1202,7 @@ fn a_start_refuses_what_the_template_cannot_serve_before_any_vmm_starts() { let cases = [ ( StartOf { - image: Some(&longer), + image: Some(Image::Erofs(&longer)), ..StartOf::default() }, "supplied-length", @@ -900,7 +1210,7 @@ fn a_start_refuses_what_the_template_cannot_serve_before_any_vmm_starts() { ), ( StartOf { - image: Some(&shorter), + image: Some(Image::Erofs(&shorter)), upper: Upper::Scratch, ..StartOf::default() }, @@ -909,7 +1219,7 @@ fn a_start_refuses_what_the_template_cannot_serve_before_any_vmm_starts() { ), ( StartOf { - image: Some(&ragged), + image: Some(Image::Erofs(&ragged)), ..StartOf::default() }, "supplied-geometry", @@ -1056,31 +1366,9 @@ fn a_clone_whose_activation_fails_ends_before_it_is_ready() { ]; for (start, (evidence, expected)) in cases { let ended = template.start("cl-u", start, CLONE_FAILED); - let printed = &ended.started; // The VM started, and ended before the clone was ready, with the status of the failure's // class, which attributes the failure to the request, and the start released the lease. - assert!(printed.contains_key("pid"), "{printed:?}"); - assert!(!printed.contains_key("root"), "{printed:?}"); - let status = expected.to_string(); - assert_eq!( - failure(printed), - [ - "request", - evidence, - "activation", - "activating", - status.as_str(), - "exited" - ], - "{printed:?}" - ); - assert_eq!(printed["quarantined"], "no"); - assert_eq!(printed["released"], printed["lease"], "{printed:?}"); - assert!(ended.sandbox.record().is_none()); - // The release removed the records of the clone's launch. - let store = SandboxStore::open(&ended.sandbox.store).unwrap(); - assert!(CloneRecords::read(&store).unwrap().is_none()); - assert!(template.leases().is_empty()); + ended.assert_unactivated(&template, evidence, expected); } // Until the clone is ready, it takes nothing but its Activate: a ping, a start, and the start @@ -1097,6 +1385,37 @@ fn a_clone_whose_activation_fails_ends_before_it_is_ready() { assert_payload(&template, false); } +#[test] +#[ignore = "boots VMs; see the module documentation for what they need"] +fn a_clone_refuses_a_scratch_image_whose_upper_an_earlier_clone_used() { + let template = Template::create("cl-fresh"); + // A clone over a scratch image, in whose root the `contents` probe writes a file, which + // reaches the upper. + let start = StartOf { + upper: Upper::Scratch, + ..StartOf::default() + }; + let mut earlier = template.start("cl-f", start, 0); + earlier.assert_started(); + let facts = earlier.probe(&["contents"]); + assert_eq!( + facts["contents.write.upper"], "runtime-adapter", + "{facts:?}" + ); + earlier.stop(); + + // A clone over a copy of its scratch image, whose upper is not fresh, ends before it is + // ready. + let used = earlier.scratch(); + let start = StartOf { + upper: Upper::Used(&used), + ..StartOf::default() + }; + let ended = template.start("cl-u", start, CLONE_FAILED); + ended.assert_unactivated(&template, "unusable-root", status::ACTIVATION_ROOT); + assert_payload(&template, false); +} + #[test] #[ignore = "boots VMs; see the module documentation for what they need"] fn a_clone_runs_its_executions_as_its_workload_identity_once_it_is_ready() { diff --git a/tests/runtime-adapter/tests/vm_layered.rs b/tests/runtime-adapter/tests/vm_layered.rs index 9a2678974..9fad1a949 100644 --- a/tests/runtime-adapter/tests/vm_layered.rs +++ b/tests/runtime-adapter/tests/vm_layered.rs @@ -53,8 +53,6 @@ use std::thread; use std::time::{Duration, Instant}; use nvx::exec::{Key, Limits, Timers, Window}; -use nvx::gpt::{Disk, Expected, ExpectedPartition}; -use nvx::uuid::Uuid; use runtime_adapter::GUEST_BUILD; use runtime_adapter::exec::Start; use runtime_adapter::frame::MAX_BODY; @@ -65,6 +63,9 @@ use runtime_adapter::root::{ use runtime_adapter::staged::STAGED_NAME; use serde_json::{Value, json}; +use common::gpt::{ + self, Contents, LAYER_BLOCK, LAYER_BLOCKS, Layout, Partition, hexadecimal, small_layer, +}; use common::{Fixtures, Printed, Sandbox}; /// The exit status of the host program's command that failed, after a diagnostic. @@ -90,8 +91,6 @@ const PARTITION_GUIDS: [&str; 3] = [ "0b7e4a2c-5d16-4f83-a9c0-1e2d3f4a5b62", "0b7e4a2c-5d16-4f83-a9c0-1e2d3f4a5b63", ]; -/// The type GUID of every partition of the scenarios' GPT disks: Linux's file system data. -const LINUX_DATA: &str = "0fc63daf-8483-4772-8e79-3d69d8477de4"; /// The size of the first partition of the scenarios' GPT disks, in bytes. const FIRST_PARTITION_SIZE: u64 = 4096; /// The bytes of the first partition that the guest returns: bytes that are not UTF-8, with a NUL @@ -109,13 +108,6 @@ const LAYER_FILE: &str = "layer-file"; const LAYER_FILE_SIZE: usize = 2500; /// The directory that the top layer of the scenarios' GPT disks holds. const LAYER_DIRECTORY: &str = "layer-directory"; -/// The size of a block of the layer that [`small_layer`] writes, in bytes. -const LAYER_BLOCK: usize = 1024; -/// The number of blocks of that layer: 128 KiB. -const LAYER_BLOCKS: usize = 128; -/// The number of inodes of that layer, of which the first ten are reserved, as in every ext2 file -/// system, and the second is its top directory. -const LAYER_INODES: usize = 16; /// The directory of a scenario's sandbox that holds the host directories and the file that a /// mapping table shares, as [`write_sources`] writes them. const SOURCES: &str = "sources"; @@ -2110,9 +2102,8 @@ fn tree(directory: &Path) -> BTreeMap> { found } -/// Writes the GPT disk of the scenarios at `path`, as UEFI lays out a disk, with a protective MBR, -/// the primary GPT header and its array of 128 entries, and the backup array and header at the -/// disk's end, of its partitions in this order, each aligned to [`ALIGNMENT`]: +/// Writes the GPT disk of the scenarios at `path`, with [`gpt::write_disk`], of its partitions in +/// this order, each aligned to [`ALIGNMENT`]: /// /// 1. the first partition, of [`FIRST_PARTITION_SIZE`] bytes, which holds [`FIRST_PARTITION`], /// then [`FIRST_PARTITION_PADDING`], then NUL bytes; @@ -2121,15 +2112,10 @@ fn tree(directory: &Path) -> BTreeMap> { /// 3. the top layer, a [`small_layer`] that holds [`LAYER_FILE`] and [`LAYER_DIRECTORY`], with /// the corruption or journal that `top_layer` names. /// -/// Then checks the disk as the guest's assembly does, through `nvx::gpt`, with the GUIDs and -/// sizes that it wrote. +/// Then checks that the first partition holds [`FIRST_PARTITION`] without its padding, as the +/// guest returns it. fn write_gpt_disk(path: &Path, template: &Path, top_layer: TopLayer) { let template_size = fs::metadata(template).unwrap().len(); - assert_eq!( - template_size % SECTOR, - 0, - "the scratch template is whole sectors" - ); let contents = b"runtime-adapter " .iter() .copied() @@ -2143,274 +2129,31 @@ fn write_gpt_disk(path: &Path, template: &Path, top_layer: TopLayer) { TopLayer::CyclicJournal => add_cyclic_journal(&mut top, false), TopLayer::NeedsRecovery => add_cyclic_journal(&mut top, true), } - let sizes = [ - FIRST_PARTITION_SIZE, - template_size, - u64::try_from(top.len()).unwrap(), + let first = [FIRST_PARTITION, FIRST_PARTITION_PADDING].concat(); + let partitions = [ + Partition { + guid: PARTITION_GUIDS[0], + size: FIRST_PARTITION_SIZE, + contents: Contents::Bytes(&first), + }, + Partition { + guid: PARTITION_GUIDS[1], + size: template_size, + contents: Contents::File(template), + }, + Partition { + guid: PARTITION_GUIDS[2], + size: u64::try_from(top.len()).unwrap(), + contents: Contents::Bytes(&top), + }, ]; - let mut extents = Vec::new(); - let mut next = ALIGNMENT; - for size in sizes { - let last = next + size / SECTOR - 1; - extents.push((next, last)); - next = (last + 1).next_multiple_of(ALIGNMENT); - } - // The backup array's 32 sectors and the backup header end the disk, after the usable sectors. - let sectors = next + ALIGNMENT; - let (first_usable, last_usable) = (34_u64, sectors - 34); - let (backup_entries, backup_header) = (sectors - 33, sectors - 1); - let guid = |text: &str| Uuid::parse(text).unwrap().to_bytes_le(); - - let mut entries = vec![0; 128 * 128]; - for (entry, ((first, last), unique)) in entries - .chunks_exact_mut(128) - .zip(extents.iter().zip(PARTITION_GUIDS)) - { - entry[0..16].copy_from_slice(&guid(LINUX_DATA)); - entry[16..32].copy_from_slice(&guid(unique)); - entry[32..40].copy_from_slice(&first.to_le_bytes()); - entry[40..48].copy_from_slice(&last.to_le_bytes()); - } - let entries_crc = crc32(&entries); - let header = |own: u64, alternate: u64, array: u64| { - let mut header = vec![0; 92]; - header[0..8].copy_from_slice(b"EFI PART"); - header[8..12].copy_from_slice(&0x0001_0000_u32.to_le_bytes()); - header[12..16].copy_from_slice(&92_u32.to_le_bytes()); - header[24..32].copy_from_slice(&own.to_le_bytes()); - header[32..40].copy_from_slice(&alternate.to_le_bytes()); - header[40..48].copy_from_slice(&first_usable.to_le_bytes()); - header[48..56].copy_from_slice(&last_usable.to_le_bytes()); - header[56..72].copy_from_slice(&guid(DISK_GUID)); - header[72..80].copy_from_slice(&array.to_le_bytes()); - header[80..84].copy_from_slice(&128_u32.to_le_bytes()); - header[84..88].copy_from_slice(&128_u32.to_le_bytes()); - header[88..92].copy_from_slice(&entries_crc.to_le_bytes()); - let crc = crc32(&header); - header[16..20].copy_from_slice(&crc.to_le_bytes()); - header + let layout = Layout { + guid: DISK_GUID, + partitions: &partitions, + alignment: ALIGNMENT, + length: None, }; - // The protective MBR: one record of type 0xEE, from LBA 1 over the rest of the disk, with the - // addresses that UEFI gives it. - let mut mbr = vec![0; 512]; - let protective = u32::try_from(sectors - 1).unwrap_or(u32::MAX); - mbr[446..450].copy_from_slice(&[0x00, 0x00, 0x02, 0x00]); - mbr[450..454].copy_from_slice(&[0xee, 0xff, 0xff, 0xff]); - mbr[454..458].copy_from_slice(&1_u32.to_le_bytes()); - mbr[458..462].copy_from_slice(&protective.to_le_bytes()); - mbr[510..512].copy_from_slice(&[0x55, 0xaa]); - - let mut disk = File::create(path).unwrap(); - disk.set_len(sectors * SECTOR).unwrap(); - let parts = [ - (0, mbr), - (1, header(1, backup_header, 2)), - (2, entries.clone()), - (backup_entries, entries), - (backup_header, header(backup_header, 1, backup_entries)), - ( - extents[0].0, - [FIRST_PARTITION, FIRST_PARTITION_PADDING].concat(), - ), - (extents[2].0, top), - ]; - for (sector, bytes) in parts { - disk.seek(SeekFrom::Start(sector * SECTOR)).unwrap(); - disk.write_all(&bytes).unwrap(); - } - disk.seek(SeekFrom::Start(extents[1].0 * SECTOR)).unwrap(); - io::copy(&mut File::open(template).unwrap(), &mut disk).unwrap(); - disk.sync_all().unwrap(); - drop(disk); - - let disk = File::open(path).unwrap(); - let partitions = PARTITION_GUIDS - .iter() - .zip(sizes) - .map(|(unique, size)| { - ExpectedPartition::new() - .unique_guid(Uuid::parse(unique).unwrap()) - .size(size) - }) - .collect(); - let expected = Expected::new() - .disk_guid(Uuid::parse(DISK_GUID).unwrap()) - .partitions(partitions); - let read = Disk::read(&disk, 512, sectors * SECTOR, &expected).unwrap(); - let first_partition = read - .read_first_partition(&disk, usize::try_from(FIRST_PARTITION_SIZE).unwrap()) - .unwrap(); - assert_eq!(first_partition, FIRST_PARTITION); -} - -/// Returns a small file system in the ext2 format, of [`LAYER_BLOCKS`] blocks of [`LAYER_BLOCK`] -/// bytes, which the kernel's ext4 driver mounts as an ext4 file system without features: its top -/// directory holds the regular `files`, each a name and its bytes, of at most 12 blocks each, -/// then the empty `directories`, each with mode 0755, and at most [`LAYER_INODES`] inodes in all. -/// -/// Block 1 holds the superblock, 2 the group descriptor, 3 and 4 the block and inode bitmaps, 5 -/// and 6 the inode table, 7 the top directory, and the blocks after them the files' bytes and the -/// directories. -fn small_layer(files: &[(&str, &[u8])], directories: &[&str]) -> Vec { - /// Writes `value` at `offset` of `image`, in little-endian order. - fn put(image: &mut [u8], offset: usize, value: [u8; N]) { - image[offset..offset + N].copy_from_slice(&value); - } - /// Writes the inode `number` of `image`, of `mode`, `size` bytes, and `links` links, whose data - /// is in `blocks`, at most the 12 that an inode addresses directly. - fn inode( - image: &mut [u8], - number: usize, - mode: u16, - size: usize, - links: usize, - blocks: &[usize], - ) { - assert!( - blocks.len() <= 12, - "an inode of the layer addresses 12 blocks" - ); - let offset = 5 * LAYER_BLOCK + (number - 1) * 128; - put(image, offset, mode.to_le_bytes()); - put( - image, - offset + 4, - u32::try_from(size).unwrap().to_le_bytes(), - ); - put( - image, - offset + 26, - u16::try_from(links).unwrap().to_le_bytes(), - ); - // The number of 512-byte sectors that the data takes. - let sectors = u32::try_from(blocks.len() * LAYER_BLOCK / 512).unwrap(); - put(image, offset + 28, sectors.to_le_bytes()); - for (index, block) in blocks.iter().enumerate() { - let block = u32::try_from(*block).unwrap(); - put(image, offset + 40 + 4 * index, block.to_le_bytes()); - } - } - /// Writes the directory `entries` into `block` of `image`, each an inode, a name, and a file - /// type, the last of which spans the rest of the block. - fn directory(image: &mut [u8], block: usize, entries: &[(usize, &str, u8)]) { - let (mut offset, end) = (block * LAYER_BLOCK, (block + 1) * LAYER_BLOCK); - for (index, (inode, name, kind)) in entries.iter().enumerate() { - let length = if index + 1 == entries.len() { - end - offset - } else { - (8 + name.len()).next_multiple_of(4) - }; - put(image, offset, u32::try_from(*inode).unwrap().to_le_bytes()); - put( - image, - offset + 4, - u16::try_from(length).unwrap().to_le_bytes(), - ); - image[offset + 6] = u8::try_from(name.len()).unwrap(); - image[offset + 7] = *kind; - image[offset + 8..offset + 8 + name.len()].copy_from_slice(name.as_bytes()); - offset += length; - } - } - /// Sets the bits `bits` of the bitmap in `block` of `image`. - fn set(image: &mut [u8], block: usize, bits: std::ops::Range) { - for bit in bits { - image[block * LAYER_BLOCK + bit / 8] |= 1 << (bit % 8); - } - } - - let (top, regular, directory_type) = (2, 1, 2); - let mut image = vec![0; LAYER_BLOCK * LAYER_BLOCKS]; - // The first block and the first inode that are free. - let (mut block, mut number) = (8, 11); - let mut entries = vec![(top, ".", directory_type), (top, "..", directory_type)]; - for (name, bytes) in files { - let blocks = (block..block + bytes.len().div_ceil(LAYER_BLOCK)).collect::>(); - image[block * LAYER_BLOCK..block * LAYER_BLOCK + bytes.len()].copy_from_slice(bytes); - inode(&mut image, number, 0o100_644, bytes.len(), 1, &blocks); - entries.push((number, *name, regular)); - block += blocks.len(); - number += 1; - } - for name in directories { - directory( - &mut image, - block, - &[(number, ".", directory_type), (top, "..", directory_type)], - ); - inode(&mut image, number, 0o040_755, LAYER_BLOCK, 2, &[block]); - entries.push((number, *name, directory_type)); - block += 1; - number += 1; - } - directory(&mut image, 7, &entries); - inode( - &mut image, - top, - 0o040_755, - LAYER_BLOCK, - 2 + directories.len(), - &[7], - ); - assert!( - block <= LAYER_BLOCKS && number <= LAYER_INODES + 1, - "the layer is too small" - ); - - // The bitmaps of the used blocks, from block 1, and inodes, from inode 1, whose bits past the - // last block and inode are set, as `mke2fs` sets them. - set(&mut image, 3, 0..block - 1); - set(&mut image, 3, LAYER_BLOCKS - 1..LAYER_BLOCK * 8); - set(&mut image, 4, 0..number - 1); - set(&mut image, 4, LAYER_INODES..LAYER_BLOCK * 8); - let free_blocks = LAYER_BLOCKS - block; - let free_inodes = LAYER_INODES + 1 - number; - - // The superblock: one group of blocks, inodes of 128 bytes, the first of them not reserved, - // 11, and only the `filetype` feature. - let superblock = LAYER_BLOCK; - for (offset, value) in [ - (0x00, LAYER_INODES), - (0x04, LAYER_BLOCKS), - (0x0c, free_blocks), - (0x10, free_inodes), - (0x14, 1), - (0x20, LAYER_BLOCK * 8), - (0x24, LAYER_BLOCK * 8), - (0x28, LAYER_INODES), - (0x4c, 1), - (0x54, 11), - (0x60, 0x2), - ] { - let value = u32::try_from(value).unwrap(); - put(&mut image, superblock + offset, value.to_le_bytes()); - } - for (offset, value) in [ - (0x36, 0xffff), - (0x38, 0xef53), - (0x3a, 1), - (0x3c, 1), - (0x58, 128), - ] { - put(&mut image, superblock + offset, u16::to_le_bytes(value)); - } - let uuid = Uuid::parse("2d4e6f80-91a2-4b3c-8d5e-6f708192a3b4").unwrap(); - image[superblock + 0x68..superblock + 0x78].copy_from_slice(uuid.as_bytes()); - - // The group descriptor: where the bitmaps and the inode table are, and what is free. - let descriptor = 2 * LAYER_BLOCK; - for (offset, value) in [(0x00, 3), (0x04, 4), (0x08, 5)] { - put(&mut image, descriptor + offset, u32::to_le_bytes(value)); - } - for (offset, value) in [ - (0x0c, free_blocks), - (0x0e, free_inodes), - (0x10, 1 + directories.len()), - ] { - let value = u16::try_from(value).unwrap(); - put(&mut image, descriptor + offset, value.to_le_bytes()); - } - image + assert_eq!(gpt::write_disk(path, &layout), FIRST_PARTITION); } /// Corrupts `layer`, a [`small_layer`]: its top directory's inode, inode 2, loses its mode, while @@ -2490,26 +2233,6 @@ fn add_cyclic_journal(layer: &mut Vec, needs_recovery: bool) { } } -/// Returns the CRC-32 of `bytes`, which GPT's headers and entry arrays hold: of the reflected -/// polynomial 0xEDB88320, from all ones, inverted. -fn crc32(bytes: &[u8]) -> u32 { - !bytes.iter().fold(!0, |crc, byte| { - (0..8).fold(crc ^ u32::from(*byte), |crc: u32, _| { - (crc >> 1) ^ (0xedb8_8320 & (crc & 1).wrapping_neg()) - }) - }) -} - -/// Returns `bytes` in lowercase hexadecimal, as the `first-partition` probe reports them. -fn hexadecimal(bytes: &[u8]) -> String { - const DIGITS: &[u8; 16] = b"0123456789abcdef"; - bytes - .iter() - .flat_map(|byte| [byte >> 4, byte & 0xf]) - .map(|digit| char::from(DIGITS[usize::from(digit)])) - .collect() -} - /// The capabilities that an entry allows, by their names in the cases of the `entries` probe, each /// with its number and the attempt of the probe that it alone allows, if any. const CAPABILITIES: [(&str, u8, Option<&str>); 13] = [