From bbba1618c419a73258858dc64f208eb258f85ae9 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sun, 25 Oct 2026 15:53:45 -0700 Subject: [PATCH 1/5] nvx-rs: export one directory to a clone and map it before its start A clone may now share host paths with its root. CloneRequest takes zero or one ExportPlan that filesystem::plan made for the container root. A template's filesystem slot is dormant, and OpenVMM binds one directory to it at a restore, so the plan must export exactly one child, a directory, which is then the export's root: - ClonePlan::new refuses, before anything is opened, a plan that check() rejects, such as mappings without an export or an export without mappings, a plan of several directories or files, and a file. It also refuses a directory inside the template store, or one that holds it, comparing the paths that the plan resolved with the store's, without reading anything. Each refusal is a CloneFailure at the plan's stage, whose evidence is the request's mappings, InvalidMappings, and which the caller holds. - The activation record names the export, a directory, by the identity of its plan, the SHA-256 digest of a domain and of the plan's serde form, and announces its mappings, the plan's binds, from 1 to 4,096. A clone without an export has neither, and its slot stays dormant. - ClonePlan::launch checks the plan with ExportPlan::verify before it writes anything, as a cold start does, and binds the directory with --mount, its hidden, re-exposed, and writable paths, and its owner. A refusal of verify, such as an object that changed or went missing since the plan, is the request's mappings, InvalidMappings, and any other failure of it the host's own; both come before the progress log, so nothing records them. To tell them apart, verify now keeps the cause of a failure to inspect an object, with ErrorKind::Io, unless the failure shows that the object's path no longer reaches it, which it still takes for a changed object, at a cold start as at a restore. - ClonePlan::table returns the binds as the mapping table, whose sources name paths in the directory without the child's index. - Activated::start sends the table through a new CloneControl::map once the clone is ready, in order, and records and sends the start only once the guest applied every mapping, both by the Ready-to-Start deadline. Neither is sent again. A partial or failed table ends the launch in the one place where a failed start ends it: the failure is recorded as pending, the launch ends through Starting::abandon, and the record is written again with the teardown. It fails at the start's stage with the clone's progress still at Ready, which tells it from a failure of the start, whose progress is Starting. - A table that the guest refused, once the framing tells it through the new provided method CloneControl::refused, is the request's mappings, InvalidMappings, whenever the refusal came. Any other failure of the table, every failure by default, is what the host saw of it: a passed deadline, Timeout; a lost session, Reset; or a broken protocol, Protocol. None is the template's, so none quarantines, and an activation status with which the guest ended its VM still prevails, since no workload ran before the start: the export's status, 56, makes the failure InvalidMappings. A reattach never sends a mapping table: the launch of a live clone committed once the clone applied it and started, and a reattach ends an interrupted launch. Activated::fail no longer gives the mapping of a host directory as an example of a step of the caller's own, since the restore sequence maps a clone's export itself. The test adapter's CloneOrder now takes only the guest agent's build: host::map_table, the table half of host::start_sandbox, implements map(), and start() sends Start alone. It keeps the default refused(): the guest's MapFailed does not tell whether the request's mappings or a failure of the guest's own failed the table, so the restore sequence takes it for a broken protocol, unless the deadline passed or the session was lost. The adapter's host program builds it in clone_order(), the one place that makes it, for a clone's start and a reattach to it. The adapter's requests name no export yet, CloneRequest.export: None, so its clones still map nothing, and the VM tests' Early framing, which ends before the clone is ready, gains a map() that it never reaches. Tests, with a fake VMM and a fake control: the record, table, and arguments of a clone with and without an export; refusals of two directories, a file, mappings without an export, an export without mappings, a reserved target, and an export that overlaps the store, with their evidence; a mapped object and an exported directory that changed or went missing since the plan, refused before the launch, and an object that cannot be inspected, the host's failure; the table after Ready and before the start; a partial, refused, failed, lost, or silent table, and a refusal past the deadline, after which the launch ends, with no start and no retry, and the sandbox records the failure at the start's stage, at Ready, with its evidence; and a table that failed as the guest ended its VM with the export's status, which prevails. ExportPlan::verify's own tests tell a path that no longer reaches its object from a failure to inspect it. The adapter's fake broker checks that the order stops the guest at a part that it fails, and takes that failure for no refusal of the request's. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 34e6ffe3-3ebf-42db-bfca-e0ecfade6873 --- nvx-rs/README.md | 54 +-- nvx-rs/src/host/filesystem/mod.rs | 66 +++- nvx-rs/src/host/filesystem/tests.rs | 95 +++++ nvx-rs/src/host/filesystem/verify.rs | 48 ++- nvx-rs/src/host/template/clone.rs | 143 ++++++-- nvx-rs/src/host/template/clone/export.rs | 131 +++++++ nvx-rs/src/host/template/clone/failure.rs | 18 +- nvx-rs/src/host/template/clone/launch.rs | 71 +++- nvx-rs/src/host/template/clone/reattach.rs | 6 +- nvx-rs/src/host/template/clone/record.rs | 68 ++-- nvx-rs/src/host/template/clone/restore.rs | 239 +++++++++---- nvx-rs/src/host/template/clone/tests.rs | 303 +++++++++++++++- .../src/host/template/clone/tests/launch.rs | 202 +++++++++++ .../src/host/template/clone/tests/restore.rs | 331 +++++++++++++++++- nvx-rs/src/host/template/mod.rs | 19 +- tests/runtime-adapter/README.md | 33 +- .../src/bin/runtime-adapter-host.rs | 7 +- tests/runtime-adapter/src/clone.rs | 1 + tests/runtime-adapter/src/host.rs | 63 +++- tests/runtime-adapter/src/host/order_error.rs | 2 +- tests/runtime-adapter/tests/host_session.rs | 68 +++- tests/runtime-adapter/tests/vm_clone.rs | 11 +- 22 files changed, 1703 insertions(+), 276 deletions(-) create mode 100644 nvx-rs/src/host/template/clone/export.rs diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 3c9385cb3..3faa33ba9 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -657,7 +657,11 @@ from the template's RAM to its capacity; the clone's image, which the check of its `DistroBinding` opens; its upper, either the caller's scratch image, which the check of its `ScratchBinding` claims, and which nothing formats, copies, or reads, or for an upper in RAM a fresh sparse file, `clone-scratch.raw` in the -sandbox's directory, which the release removes; and a fresh capability. +sandbox's directory, which the release removes; its export, if it has one, an +`ExportPlan` that `filesystem::plan` made for the container root, which must +export exactly one directory, since the template's dormant filesystem slot binds +one directory and no file, and which must not overlap the template store, by the +paths that the plan resolved; 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, @@ -668,11 +672,13 @@ 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 +or the scratch image's binding identity with the journal policy; the export, a +directory, with its plan's identity, and its mappings, the plan's binds, from 1 +to 4,096, or no export and no mappings, which leaves the slot dormant; 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 identity of a binding or of an export plan is the SHA-256 digest of a domain +and of its 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 @@ -685,14 +691,17 @@ store's names is refused. `ClonePlan::launch` then launches the clone as the launch that its lease reserves, once it has checked that the sandbox's clone binding records the -lease, and that the sandbox records no failure of the clone, which fails once -under its lease, and returns a `Restoring`: it writes the clone's activation -record in its canonical encoding, `clone-activation.bin`, from which a caller -resends it unchanged; it listens for the VMM's restore-ready event, at the -socket `restore.sock` on Linux, and at a named pipe of a random name on Windows; -and it runs `supervisor::launch` with OpenVMM's restore of the clone's devices, -`--restore-snapshot` with the generation's snapshot in place, which no clone -copies or changes, so that clones of one generation restore it at once, +lease, that the sandbox records no failure of the clone, which fails once under +its lease, and, with `ExportPlan::verify`, that the objects of its export, if it +has one, are still those that its plan found, and returns a `Restoring`: it +writes the clone's activation record in its canonical encoding, +`clone-activation.bin`, from which a caller resends it unchanged; it listens for +the VMM's restore-ready event, at the socket `restore.sock` on Linux, and at a +named pipe of a random name on Windows; and it runs `supervisor::launch` with +OpenVMM's restore of the clone's devices, its export bound to the dormant slot, +`--mount` with the directory, and its hidden, re-exposed, and writable paths and +its owner, `--restore-snapshot` with the generation's snapshot in place, which +no clone copies or changes, so that clones of one generation restore it at once, `--restore-memory` with the memory target, and `--restore-ready-path`, under the template's backend, CPU profile, and processors, with a boot console that discards its output, as cold boots have, and the control console, but no kernel, @@ -728,18 +737,23 @@ lost, as `CloneControl::lost` tells, with a new one that sends the same record, in at most `MAX_ACTIVATION_SESSIONS` sessions by the same deadline, and retries nothing else. -`Activated::start` then starts the sandbox by a Ready-to-Start deadline from the -check of Ready, `READY_TO_START_TIMEOUT`, 30 seconds by default, never sending -the start again, and `Started::commit` commits the launch, which writes its -runtime record. A failure ends the launch as `Starting::abandon` does; a crash -before the commit leaves the launch's journal, whose recovery ends the VMM, -while the lease lasts until the release, and a crash after it leaves a VM that -reattaches, and keeps its lease while it runs. +`Activated::start` then sends the clone's mapping table, if it has an export, +through `CloneControl::map`, in order, from paths in the exported directory, +which name no child's index, and starts the sandbox once the guest applied every +mapping, both by a Ready-to-Start deadline from the check of Ready, +`READY_TO_START_TIMEOUT`, 30 seconds by default, never sending either again, and +`Started::commit` commits the launch, which writes its runtime record. A +failure, a partial or failed mapping table included, ends the launch as +`Starting::abandon` does; a crash before the commit leaves the launch's journal, +whose recovery ends the VMM, while the lease lasts until the release, and a +crash after it leaves a VM that reattaches, and keeps its lease while it runs. A failure of a clone is a `CloneFailure`, whose `Evidence` attributes it to an `Outcome`, at a `Stage`, once the clone got as far as its `Progress`: the request, for a denied authorization, a stale binding, invalid targets, format, -settings, Activate, or mappings, or an image, a scratch, or a root that the +settings, Activate, or mappings, such as an export that no clone takes, or whose +objects changed since its plan, or a mapping table that the guest refused, as +`CloneControl::refused` tells, or an image, a scratch, or a root that the template cannot serve, and for a supplied file of the wrong identity, length, or geometry, which the host's staging takes instead once the caller proves it, `CloneFailure::with_staging_proven`; the host, for an endpoint that cannot be diff --git a/nvx-rs/src/host/filesystem/mod.rs b/nvx-rs/src/host/filesystem/mod.rs index 7ae455e3a..4fdc6094f 100644 --- a/nvx-rs/src/host/filesystem/mod.rs +++ b/nvx-rs/src/host/filesystem/mod.rs @@ -532,7 +532,7 @@ pub fn plan(request: &ExportRequest<'_>) -> Result> { .then_with(|| left.target.cmp(&right.target)) }); if request.owner == Owner::Caller { - check_caller_roots(&children)?; + check_caller_roots(&children, unsupported_inspection)?; } let plan = ExportPlan { children, @@ -711,16 +711,18 @@ fn shows(outer: &Mapped<'_>, inner: &Mapped<'_>) -> bool { } /// Refuses an exported directory or file that belongs to UID 0 or GID 0, to which the caller -/// owner would squash guest root. -fn check_caller_roots(children: &[ExportChild]) -> Result<()> { +/// owner would squash guest root, and fails as `uninspected` returns for one whose owner cannot be +/// read, with its path and the failure. +fn check_caller_roots( + children: &[ExportChild], + uninspected: fn(&Path, std::io::Error) -> Error, +) -> Result<()> { #[cfg(unix)] for child in children { use std::os::unix::fs::MetadataExt; - let metadata = fs::metadata(&child.root).map_err(|error| { - Error::unsupported(format!("cannot inspect {}", child.root.display())) - .with_source(error) - })?; + let metadata = + fs::metadata(&child.root).map_err(|error| uninspected(&child.root, error))?; if metadata.uid() == 0 || metadata.gid() == 0 { return Err(Error::unsupported(format!( "the caller owner squashes guest root to the owner of each exported directory or \ @@ -730,10 +732,58 @@ fn check_caller_roots(children: &[ExportChild]) -> Result<()> { } } #[cfg(not(unix))] - let _ = children; + let _ = (children, uninspected); Ok(()) } +/// Returns the failure of a plan that cannot inspect `path`, with `error`, which refuses the +/// request. +fn unsupported_inspection(path: &Path, error: std::io::Error) -> Error { + Error::unsupported(format!("cannot inspect {}", path.display())).with_source(error) +} + +/// Returns the failure of [`ExportPlan::verify`] for `path`, which no longer names the host object +/// that the plan found there. +fn changed_object(path: &Path) -> Error { + Error::other(format!( + "{} no longer names the host object it named at provision; deprovision the sandbox and \ + provision it again", + path.display() + )) +} + +/// Returns the failure of [`ExportPlan::verify`] that cannot inspect `path`, an object that the +/// plan found, with `error`: a path that no longer reaches the object, as the host sees it, whose +/// object went, a component of which is no longer a directory or is a link that [`loops`], or +/// that the host may no longer search or open, names it no longer, [`changed_object`], and any +/// other failure is the host's own, which shows nothing of the object. +fn uninspected(path: &Path, error: std::io::Error) -> Error { + if matches!( + error.kind(), + std::io::ErrorKind::NotFound + | std::io::ErrorKind::NotADirectory + | std::io::ErrorKind::PermissionDenied + ) || loops(&error) + { + changed_object(path).with_source(error) + } else { + Error::io(format!("cannot inspect {}", path.display())).with_source(error) + } +} + +/// Returns whether `error` is the host's refusal to resolve a path whose links loop, or nest +/// deeper than the host follows them, as a link that names itself does: the failure that the +/// standard library's unstable `ErrorKind::FilesystemLoop` names. +fn loops(error: &std::io::Error) -> bool { + #[cfg(unix)] + let code = Some(nix::errno::Errno::ELOOP as i32); + #[cfg(windows)] + let code = Some(windows_sys::Win32::Foundation::ERROR_CANT_RESOLVE_FILENAME as i32); + #[cfg(not(any(unix, windows)))] + let code = None; + code.is_some() && error.raw_os_error() == code +} + /// Explains why a mapped host directory, given in canonical form, cannot be exported, or returns /// `None` if it can: a whole volume. It inspects only the path, not the host. fn volume_root_refusal(canonical: &Path) -> Option<&'static str> { diff --git a/nvx-rs/src/host/filesystem/tests.rs b/nvx-rs/src/host/filesystem/tests.rs index cfc7a6c6c..9cf6c9c72 100644 --- a/nvx-rs/src/host/filesystem/tests.rs +++ b/nvx-rs/src/host/filesystem/tests.rs @@ -901,6 +901,101 @@ fn swapped_objects_fail_verification() { assert_eq!(mapping.verify().unwrap_err().kind(), ErrorKind::Other); } +/// A path that no longer reaches its object names it no longer, but any other failure to +/// inspect an object shows nothing of it, so it is the host's own, with its cause. +#[test] +fn objects_that_cannot_be_inspected_fail_verification_by_what_the_failure_shows() { + let directory = root(); + let base = directory.path(); + let policy = FilesystemPolicy { + readwrite_paths: vec![base.join("work"), base.join("work/src")], + denied_paths: vec![base.join("work/src/secret")], + ..FilesystemPolicy::default() + }; + let mapping = plan(&policy).unwrap().unwrap(); + mapping.verify().unwrap(); + // A pinned object at a name that the host's file system cannot hold. + let mut unnamed = mapping.clone(); + let pinned = unnamed + .binds + .iter() + .position(|bind| bind.identity.is_some()) + .unwrap(); + unnamed.binds[pinned].source = "n".repeat(300); + let bind = &unnamed.binds[pinned]; + let path = unnamed.children[bind.child].root.join(&bind.source); + let error = unnamed.verify().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io, "{error}"); + assert_eq!( + error.message(), + format!("cannot inspect {}", path.display()) + ); + let source = std::error::Error::source(&error).unwrap(); + assert!( + source.downcast_ref::().is_some(), + "{source}" + ); + // A pinned object that went. + fs::remove_dir(base.join("work/src/secret")).unwrap(); + let error = mapping.verify().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Other, "{error}"); + assert!(error.message().contains("no longer names"), "{error}"); + // A link in its place that names itself, which a workload that may write the directory could + // put there, and through which the host reaches no object. + #[cfg(unix)] + { + std::os::unix::fs::symlink("secret", base.join("work/src/secret")).unwrap(); + let error = mapping.verify().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Other, "{error}"); + assert!(error.message().contains("no longer names"), "{error}"); + let source = std::error::Error::source(&error).unwrap(); + assert_eq!( + source + .downcast_ref::() + .unwrap() + .raw_os_error(), + Some(nix::errno::Errno::ELOOP as i32) + ); + } + // How the host failed to inspect an object tells whether its path still reaches it. + let path = base.join("work"); + for (kind, expected) in [ + (std::io::ErrorKind::NotFound, ErrorKind::Other), + (std::io::ErrorKind::NotADirectory, ErrorKind::Other), + (std::io::ErrorKind::PermissionDenied, ErrorKind::Other), + (std::io::ErrorKind::StorageFull, ErrorKind::Io), + (std::io::ErrorKind::OutOfMemory, ErrorKind::Io), + (std::io::ErrorKind::InvalidFilename, ErrorKind::Io), + (std::io::ErrorKind::Other, ErrorKind::Io), + ] { + let error = uninspected(&path, kind.into()); + assert_eq!(error.kind(), expected, "{kind:?}"); + let source = std::error::Error::source(&error).unwrap(); + assert_eq!( + source.downcast_ref::().unwrap().kind(), + kind + ); + } + // A path whose links loop, which the host reports with a code of its own. + #[cfg(unix)] + let looped = nix::errno::Errno::ELOOP as i32; + #[cfg(windows)] + let looped = windows_sys::Win32::Foundation::ERROR_CANT_RESOLVE_FILENAME as i32; + #[cfg(any(unix, windows))] + { + let error = uninspected(&path, std::io::Error::from_raw_os_error(looped)); + assert_eq!(error.kind(), ErrorKind::Other, "{error}"); + let source = std::error::Error::source(&error).unwrap(); + assert_eq!( + source + .downcast_ref::() + .unwrap() + .raw_os_error(), + Some(looped) + ); + } +} + /// A recorded plan with a read-only bind in a part of a child that the guest may write, which /// only the guest's bind mount would keep read-only, is refused before a guest runs. #[test] diff --git a/nvx-rs/src/host/filesystem/verify.rs b/nvx-rs/src/host/filesystem/verify.rs index c457be500..81f6373bb 100644 --- a/nvx-rs/src/host/filesystem/verify.rs +++ b/nvx-rs/src/host/filesystem/verify.rs @@ -1,7 +1,7 @@ //! Checks and rendering of a recorded export plan. use std::fs; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use super::guest_path::depth; use super::host_path::{display_path, relative_text}; @@ -10,7 +10,8 @@ use super::policy::{Writable, nearest_rule, policy_rule_problem}; use super::{ Bind, CALLER_OWNER_ON_WINDOWS, ExportChild, ExportPlan, FileIdentity, GUEST_EXPORT, MAX_AGGREGATE_POLICY_BYTES, MAX_CHILDREN, MAX_GUEST_PATH, Owner, ReservedTargets, - check_caller_roots, outermost, resolved_path_problem, target_problem, volume_root_refusal, + changed_object, check_caller_roots, outermost, resolved_path_problem, target_problem, + uninspected, volume_root_refusal, }; use crate::error::{Error, Result}; use crate::host::platform; @@ -328,11 +329,15 @@ impl ExportPlan { /// would otherwise grant the whole directory, and a workload that may write a directory could /// rename a denied or mapped object inside it and put a decoy at its path, so the next start /// would hide or bind the decoy instead; these fail with - /// [`ErrorKind::Other`](crate::ErrorKind::Other). With the caller owner, the host must be a - /// Linux host, and no exported directory or file may belong to UID 0 or GID 0; these fail with - /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). Run it while the VM is stopped, - /// so that no workload can race with it; OpenVMM checks each child's kind again when it opens - /// it. + /// [`ErrorKind::Other`](crate::ErrorKind::Other), as does a path that no longer reaches its + /// object, as the host sees it: one whose object went, one a component of which is no longer a + /// directory or is a link that loops, as a link that names itself does, and one that the host + /// may no longer search or open. With the caller owner, the + /// host must be a Linux host, and no exported directory or file may belong to UID 0 or GID 0; + /// these fail with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). Any other + /// failure to inspect an object, which shows nothing of the object, fails with + /// [`ErrorKind::Io`](crate::ErrorKind::Io), with its cause. Run it while the VM is stopped, so + /// that no workload can race with it; OpenVMM checks each child's kind again when it opens it. pub fn verify(&self) -> Result<()> { for bind in self.binds.iter().filter(|bind| bind.read_only) { let (child, path) = self.bound(bind)?; @@ -350,23 +355,14 @@ impl ExportPlan { ))); } } - let changed = |path: &Path| { - Err(Error::other(format!( - "{} no longer names the host object it named at provision; deprovision the \ - sandbox and provision it again", - path.display() - ))) - }; for child in &self.children { - let kind_matches = fs::symlink_metadata(&child.root).is_ok_and(|metadata| { - if child.file { - metadata.is_file() - } else { - metadata.is_dir() - } - }); + let kind_matches = match fs::symlink_metadata(&child.root) { + Ok(metadata) if child.file => metadata.is_file(), + Ok(metadata) => metadata.is_dir(), + Err(error) => return Err(uninspected(&child.root, error)), + }; if !kind_matches { - return changed(&child.root); + return Err(changed_object(&child.root)); } } for bind in &self.binds { @@ -376,7 +372,8 @@ impl ExportPlan { let (_, path) = self.bound(bind)?; match platform::file_identity(&path) { Ok((device, index)) if (FileIdentity { device, index }) == expected => {} - _ => return changed(&path), + Ok(_) => return Err(changed_object(&path)), + Err(error) => return Err(uninspected(&path, error)), } } for child in &self.children { @@ -386,7 +383,8 @@ impl ExportPlan { }; match platform::file_identity(path) { Ok((device, index)) if (FileIdentity { device, index }) == *expected => {} - _ => return changed(path), + Ok(_) => return Err(changed_object(path)), + Err(error) => return Err(uninspected(path, error)), } } } @@ -394,7 +392,7 @@ impl ExportPlan { if cfg!(windows) { return Err(Error::unsupported(CALLER_OWNER_ON_WINDOWS)); } - check_caller_roots(&self.children)?; + check_caller_roots(&self.children, uninspected)?; } Ok(()) } diff --git a/nvx-rs/src/host/template/clone.rs b/nvx-rs/src/host/template/clone.rs index 626262e68..89d3f03bc 100644 --- a/nvx-rs/src/host/template/clone.rs +++ b/nvx-rs/src/host/template/clone.rs @@ -1,12 +1,13 @@ //! The plan of a clone, [`ClonePlan`]: the generation of a template that a clone leased, with the -//! clone's own image, upper, and memory target, which the plan checks against the generation -//! before the clone's launch, so that a request that the template cannot serve fails before any -//! VMM starts; and the clone's launch, [`ClonePlan::launch`], OpenVMM's restore of the +//! clone's own image, upper, memory target, and export, which the plan checks against the +//! generation before the clone's launch, so that a request that the template cannot serve fails +//! before any VMM starts; and the clone's launch, [`ClonePlan::launch`], OpenVMM's restore of the //! generation's snapshot with what the plan checked. #[cfg(all(test, any(target_os = "linux", windows)))] mod tests; +mod export; mod failure; mod launch; mod reattach; @@ -31,6 +32,7 @@ use std::io; use std::path::{Path, PathBuf}; use std::time::Instant; +use self::export::DirectoryExport; use self::failure::{Attribute, Fault, host_evidence}; use super::key::TemplateKey; use super::manifest::BlockGeometry; @@ -39,7 +41,7 @@ use super::store::{Acquired, Generation, Lease, Store}; use crate::activation::{ActivationRecord, Journal, OverlayMode, WorkloadView}; use crate::contract; use crate::error::{Error, ErrorCode, ErrorKind, Result}; -use crate::host::filesystem; +use crate::host::filesystem::{self, ExportPlan}; use crate::host::instance::generate_capability; use crate::host::paths; use crate::host::platform; @@ -55,7 +57,7 @@ use crate::host::sandbox::{ use crate::host::store::LockedStore; use crate::host::store::files::{create_new, remove_if_present}; use crate::host::store::regular::{identity_of, is_refused, open_regular, was_removed}; -use crate::protocol::CAPABILITY_LEN; +use crate::protocol::{CAPABILITY_LEN, HostMap}; /// Bytes in a mebibyte, the unit in which messages give the size of memory blocks. const MIB: u64 = 1024 * 1024; @@ -65,11 +67,11 @@ const SPARSE_WHAT: &str = "scratch device of the clone"; /// What the clone of a sandbox restores of the generation of a template that it leased: the /// adapter's plan of the clone's image and upper, the binding of the image, the binding of the -/// 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, the journal policy of a scratch image, and the bound of a GPT -/// disk's first partition. +/// scratch image of an upper on one, the export of the host paths that the clone shares with its +/// root, if any, 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, 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 @@ -90,6 +92,13 @@ pub struct CloneRequest { pub policy_revision: u32, /// The binding of the scratch image of an upper on one, or `None` for an upper in RAM. pub scratch: Option, + /// The export of the host paths that the clone shares with its root, which + /// [`filesystem::plan`] planned for the container root, + /// [`ReservedTargets::CONTAINER_ROOT`](crate::host::filesystem::ReservedTargets::CONTAINER_ROOT), + /// or `None` for a clone that shares none, whose restore leaves the template's filesystem slot + /// dormant. The slot binds one directory, so the plan must export exactly one, and no file, + /// as [`ClonePlan`] describes. + pub export: Option, /// The memory target, in bytes: the RAM of the clone's VM once restored, to which the restore /// expands the template's RAM in whole blocks. pub memory: u64, @@ -181,10 +190,19 @@ pub enum CloneUpper { /// the checked image, and with it the claim. Nothing reads, formats, or copies the image. /// - The memory target must be whole blocks of [`MEMORY_BLOCK_SIZE`], and of the template's own /// memory blocks, from the template's RAM to its RAM capacity. +/// - The export, if the request has one, must be a plan that [`filesystem::plan`] could have made +/// for the container root, as [`ExportPlan::check`] finds it, of exactly one child, a +/// directory: a template's filesystem slot is dormant, and a restore binds one directory to it, +/// which is then the export's root, in which the sources of the plan's binds name their paths +/// without the child's index, as the mapping table, [`table`](Self::table), sends them. The +/// directory may neither lie inside the template store's directory nor hold it, by the path +/// that the plan resolved, so that no guest reaches the payload of a generation through it. The +/// launch checks the plan's host objects again, with [`ExportPlan::verify`], before it restores +/// the clone. A request without an export leaves the slot dormant. /// - 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 settings must keep the limits of a +/// upper, export, and settings, the template's network, and the resources that the clone must +/// have, 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). /// @@ -222,6 +240,7 @@ pub enum CloneUpper { /// binding, /// policy_revision: DISTRO_POLICY_REVISION, /// scratch: None, +/// export: None, /// memory: 8 * MEMORY_BLOCK_SIZE, /// workload: WorkloadView { /// hostname: b"clone".to_vec(), @@ -267,6 +286,9 @@ pub struct ClonePlan { /// The clone's devices, which its restore attaches: the adapter's plan of its image and /// network, over the file of its `scratch` block device. devices: SandboxPlan, + /// The clone's export, which its restore binds to the template's dormant filesystem slot, if + /// it has one. + export: Option, } /// How a plan reaches the clone's files: as the checks of their bindings reach them, and as it @@ -322,10 +344,18 @@ impl ClonePlan { /// 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`]; + /// in RAM with a binding, [`Evidence::InvalidSettings`]; and an export plan that + /// [`filesystem::plan`] could not have made for the container root, as [`ExportPlan::check`] + /// finds it, such as one that maps paths but exports no directory or file, or exports some + /// but maps no path, and one that exports more than one directory or file, or a file, which + /// the template's dormant filesystem slot does not bind, [`Evidence::InvalidMappings`]; /// - 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`]; + /// - with [`ErrorKind::Unsupported`] for an export whose directory, by the path that its plan + /// resolved, lies inside the template store's directory, [`Store::dir`], or holds it, which + /// the plan compares without reading anything, and so without resolving the store's own + /// path, [`Evidence::InvalidMappings`]; /// - with [`ErrorKind::NotFound`] if the store no longer indexes the leased generation, and as /// [`Store::index`] fails, the host's evidence; with [`ErrorKind::Io`] if it indexes it with /// another key than the lease names, [`Evidence::MalformedGeneration`]; and as @@ -436,9 +466,14 @@ impl ClonePlan { deadline: Instant, access: Access<'_>, ) -> Result { - check_request(request)?; + let export = check_request(request)?; let Acquired { lease, locked } = acquired; check_lease(store, lease, locked).attribute(Evidence::HostFailure)?; + if let Some(export) = &export { + export + .check_outside(store.dir()) + .attribute(Evidence::InvalidMappings)?; + } let generation = store .index() .on_host()? @@ -508,6 +543,7 @@ impl ClonePlan { activation, capability, devices, + export, }) } @@ -559,6 +595,25 @@ impl ClonePlan { self.memory } + /// Returns the export plan of the host paths that the clone shares with its root, which its + /// restore binds to the template's dormant filesystem slot, or `None` for a clone that shares + /// none, whose slot stays dormant. + pub fn export(&self) -> Option<&ExportPlan> { + self.export.as_ref().map(DirectoryExport::plan) + } + + /// Returns the clone's mapping table, which its restore sequence sends once the clone is + /// ready, before its sandbox starts, as [`Activated::start`] describes: the binds of the + /// export plan, in their order, each from its path in the exported directory, without the + /// child's index, which a directory export's root does not list, and with an empty source for + /// the directory itself. A clone without an export has an empty table, and the sequence sends + /// none. + pub fn table(&self) -> Vec { + self.export + .as_ref() + .map_or_else(Vec::new, DirectoryExport::table) + } + /// Returns the clone's activation record, which its guest applies in place of the /// configuration that the template captured, and whose restore transaction ID, as /// [`ReadyActivation::of`](crate::activation::ReadyActivation::of) returns it, the guest's @@ -571,15 +626,19 @@ impl ClonePlan { /// 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 + /// the export, if the request has one, a directory, with the identity of its plan, and the + /// number of its mappings, the binds of the plan, from 1 to + /// [`MAX_MAPPINGS`](crate::activation::MAX_MAPPINGS), or no export and no mappings; 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 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. + /// The identity of a binding, or of an export plan, 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. pub fn activation(&self) -> &ActivationRecord { &self.activation } @@ -603,6 +662,7 @@ impl fmt::Debug for ClonePlan { .field("upper", &self.upper) .field("memory", &self.memory) .field("activation", &self.activation) + .field("export", &self.export()) .finish_non_exhaustive() } } @@ -620,8 +680,8 @@ fn devices_of(request: &CloneRequest, upper: &CloneUpper) -> SandboxPlan { } /// Refuses a request that no clone takes, as [`ClonePlan::new`] describes, without reading or -/// opening anything. -fn check_request(request: &CloneRequest) -> Result<(), Fault> { +/// opening anything, and returns the request's export, if it has one, which the checks admitted. +fn check_request(request: &CloneRequest) -> Result, Fault> { let adapter = &request.adapter; if adapter.settings().is_some() { return Err(Fault::new( @@ -648,21 +708,32 @@ fn check_request(request: &CloneRequest) -> Result<(), Fault> { check_disk_path(distro.path(), "image").attribute(Evidence::InvalidSettings)?; match (adapter.upper().scratch_path(), &request.scratch) { (Some(path), Some(_)) => { - check_disk_path(path, "scratch image").attribute(Evidence::InvalidSettings) + check_disk_path(path, "scratch image").attribute(Evidence::InvalidSettings)?; + } + (None, None) => {} + (Some(path), None) => { + return Err(Fault::new( + Evidence::InvalidSettings, + Error::unsupported(format!( + "the upper of a clone on the scratch image {} needs the image's scratch \ + binding", + path.display() + )), + )); + } + (None, Some(_)) => { + return Err(Fault::new( + Evidence::InvalidSettings, + Error::unsupported("the upper of a clone in RAM takes no scratch binding"), + )); } - (None, None) => Ok(()), - (Some(path), None) => Err(Fault::new( - Evidence::InvalidSettings, - Error::unsupported(format!( - "the upper of a clone on the scratch image {} needs the image's scratch binding", - path.display() - )), - )), - (None, Some(_)) => Err(Fault::new( - Evidence::InvalidSettings, - Error::unsupported("the upper of a clone in RAM takes no scratch binding"), - )), } + request + .export + .as_ref() + .map(DirectoryExport::new) + .transpose() + .attribute(Evidence::InvalidMappings) } /// Returns the fault of `error`, with which the template store's read of the key or of the diff --git a/nvx-rs/src/host/template/clone/export.rs b/nvx-rs/src/host/template/clone/export.rs new file mode 100644 index 000000000..d0fc8eca9 --- /dev/null +++ b/nvx-rs/src/host/template/clone/export.rs @@ -0,0 +1,131 @@ +//! The export of a clone: the host paths that it shares with its root, as +//! [`filesystem::plan`](crate::host::filesystem::plan) plans them, which +//! [`ClonePlan::new`](super::ClonePlan::new) checks before the clone's launch, and the forms in +//! which the clone's restore attaches the export, on the template's dormant filesystem slot, and +//! in which its restore sequence sends the export's mapping table. +//! +//! A template's capture leaves its filesystem slot dormant, so that each restore binds a clone's +//! own export to it, or none. OpenVMM binds one directory to a dormant slot, with `--mount`, but +//! no aggregate, so a clone's export plan must export exactly one child, a directory: the +//! directory is then the export's root, which the clone's guest mounts, and the sources of the +//! plan's binds name paths in it without the child's index, which only an aggregate's root lists. + +use std::path::Path; + +use crate::error::{Error, Result}; +use crate::host::filesystem::{ExportPlan, GUEST_EXPORT, Owner, ReservedTargets}; +use crate::protocol::HostMap; + +/// The export of a clone that [`ClonePlan::new`](super::ClonePlan::new) checked: an export plan +/// of one child, a directory, which its restore binds to the template's dormant filesystem slot. +#[derive(Debug, Clone)] +pub(super) struct DirectoryExport { + /// The plan, whose one child is a directory. + plan: ExportPlan, +} + +impl DirectoryExport { + /// Returns the export of `plan`, an export plan of a clone's request, without reading + /// anything. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a plan that + /// [`filesystem::plan`](crate::host::filesystem::plan) could not have made for the container + /// root, [`ReservedTargets::CONTAINER_ROOT`], as [`ExportPlan::check`] finds it, such as one + /// that maps paths but exports no directory or file, or exports some but maps no path; and for + /// a plan that exports more than one directory or file, or a file, which a dormant filesystem + /// slot does not bind. + pub(super) fn new(plan: &ExportPlan) -> Result { + plan.check(ReservedTargets::CONTAINER_ROOT)?; + match plan.children.as_slice() { + [child] if !child.file => Ok(Self { plan: plan.clone() }), + [child] => Err(Error::unsupported(format!( + "the export of a clone binds one directory to its template's dormant filesystem \ + slot, but its export plan exports the file {}", + child.root.display() + ))), + children => Err(Error::unsupported(format!( + "the export of a clone binds one directory to its template's dormant filesystem \ + slot, but its export plan exports {} directories and files", + children.len() + ))), + } + } + + /// Returns the export's plan. + pub(super) fn plan(&self) -> &ExportPlan { + &self.plan + } + + /// Refuses the export if its directory lies inside the directory of a template store, + /// `store`, or holds it: the guest could then reach the payload of a generation that clones + /// lease, and write it through a writable export, though nothing may change a leased payload + /// or its snapshot. + /// + /// The check reads nothing: it compares the directory's path, as the plan resolved it, with + /// the store's, [`Store::dir`](crate::host::template::Store::dir), as the store names it, + /// which the check does not resolve, so it does not find an overlap that only the store's + /// resolved path shows, through a link, or on Windows another letter case or a short name. + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for such an export. + pub(super) fn check_outside(&self, store: &Path) -> Result<()> { + for child in &self.plan.children { + if child.root.starts_with(store) || store.starts_with(&child.root) { + return Err(Error::unsupported(format!( + "the export of a clone cannot share {}, which overlaps the template store {}, \ + whose leased payloads nothing may change", + child.root.display(), + store.display() + ))); + } + } + Ok(()) + } + + /// Returns the export's mapping table, which the clone's restore sequence sends once the + /// clone is ready: the plan's binds, in their order, each from its path in the export's + /// directory, which a source names without the child's index, and an empty source for the + /// directory itself, to its target, with its access. + pub(super) fn table(&self) -> Vec { + self.plan + .binds + .iter() + .map(|bind| HostMap { + source: bind.source.clone(), + target: bind.target.clone(), + read_only: bind.read_only, + }) + .collect() + } + + /// Returns OpenVMM's arguments that bind the export to the template's dormant filesystem slot + /// at a restore: `--mount` with [`GUEST_EXPORT`], the directory, and its mode, `rw` if the + /// guest may write it, and `ro` otherwise, always given, so that the directory's path may hold + /// commas; then the directory's hidden, re-exposed, and writable paths, with `--mount-deny`, + /// `--mount-allow`, and `--mount-write`; and `--mount-owner caller` for an export of the + /// caller owner. Each path is absolute, as the plan resolved it. + pub(super) fn arguments(&self) -> Vec { + let mut arguments = Vec::new(); + // The export has one child, which `new` requires. + for child in &self.plan.children { + let mode = if child.writable { "rw" } else { "ro" }; + arguments.push("--mount".to_owned()); + arguments.push(format!( + "{GUEST_EXPORT},{},{mode}", + child.root.to_string_lossy() + )); + for (option, paths) in [ + ("--mount-deny", &child.denied), + ("--mount-allow", &child.allowed), + ("--mount-write", &child.write), + ] { + for path in paths { + arguments.push(option.to_owned()); + arguments.push(path.to_string_lossy().into_owned()); + } + } + } + if self.plan.owner == Owner::Caller { + arguments.extend(["--mount-owner".to_owned(), "caller".to_owned()]); + } + arguments + } +} diff --git a/nvx-rs/src/host/template/clone/failure.rs b/nvx-rs/src/host/template/clone/failure.rs index 99a46ecd0..f3aec70d6 100644 --- a/nvx-rs/src/host/template/clone/failure.rs +++ b/nvx-rs/src/host/template/clone/failure.rs @@ -94,8 +94,12 @@ pub enum Evidence { /// that the guest refused, as its /// [`ACTIVATION_RECORD`](crate::protocol::status::ACTIVATION_RECORD) tells. InvalidActivate, - /// The request's mappings are invalid: the guest cannot mount the host export that they map, - /// as its [`ACTIVATION_EXPORT`](crate::protocol::status::ACTIVATION_EXPORT) tells. + /// The request's mappings are invalid: an export that no clone takes, such as one of more + /// than one directory or of a file, or one that overlaps the template store; an export whose + /// read-only mappings the host cannot keep read-only, or whose objects are no longer those + /// that its plan found, a missing one included; a mapping table that the guest refused, as + /// the adapter's framing tells; or a host export that the guest cannot mount, as its + /// [`ACTIVATION_EXPORT`](crate::protocol::status::ACTIVATION_EXPORT) tells. InvalidMappings, /// The request's image is one that the template cannot serve, as a diagnostic tells. No /// clone's restore keeps the guest's diagnostics yet, so the guest's status alone, @@ -314,8 +318,8 @@ pub enum Stage { /// The clone's repair and activation, from the restore-ready event until the check of the /// clone's Ready. Activation, - /// The start of the clone's sandbox, from the check of its Ready until the start's - /// confirmation. + /// The start of the clone's sandbox, from the check of its Ready, through the mapping table of + /// a clone with an export, until the start's confirmation. Start, /// The commit of the clone's launch. Commit, @@ -372,9 +376,11 @@ pub enum Progress { Acknowledged, /// The Activate of the clone's record was sent. Activating, - /// The clone's Ready was checked: the clone is activated, with the record's transaction. + /// The clone's Ready was checked: the clone is activated, with the record's transaction. A + /// clone with an export gets its mapping table at this step, before the start. Ready, - /// The start of the clone's sandbox was sent. + /// The start of the clone's sandbox was sent, once the guest applied the whole mapping table + /// of a clone with an export. Starting, /// The start was confirmed: the clone's sandbox started. Started, diff --git a/nvx-rs/src/host/template/clone/launch.rs b/nvx-rs/src/host/template/clone/launch.rs index 77e12d5b3..24470078e 100644 --- a/nvx-rs/src/host/template/clone/launch.rs +++ b/nvx-rs/src/host/template/clone/launch.rs @@ -16,7 +16,7 @@ use super::failure::{ use super::records::{End, ProgressLog, Recorder, Tracker, abandon}; use super::{ClonePlan, CloneUpper, MIB, Mismatch, SPARSE_WHAT, check_geometry}; use crate::activation::ActivationRecord; -use crate::error::{Error, Result}; +use crate::error::{Error, ErrorKind, Result}; use crate::host::artifacts; use crate::host::filesystem; use crate::host::platform::{self, ReadyListener}; @@ -29,7 +29,7 @@ use crate::host::supervisor::{self, Inherited, Starting, VmmCommand}; use crate::host::template::key::TemplateKey; use crate::host::template::manifest::BlockGeometry; use crate::host::template::store::{Acquired, Lease, SOCKET_PATH_LIMIT}; -use crate::protocol::CAPABILITY_LEN; +use crate::protocol::{CAPABILITY_LEN, HostMap}; /// A point of a clone's launch at which a test interrupts it, before the step that follows the /// point. @@ -132,15 +132,20 @@ impl ClonePlan { /// which OpenVMM opens at their paths, as [Block devices by /// path](crate::host::template::Store#block-devices-by-path) describes, and the policy of the /// template's NIC, if it has one, without the guest's address, which OpenVMM takes from the - /// snapshot. Then come `--single-process`, the template's backend, CPU profile, and - /// processors, `--restore-snapshot` with the directory of the generation's snapshot, - /// [`ClonePlan::snapshot`], `--restore-memory` with the memory target, `--restore-ready-path` - /// with the endpoint of the restore-ready event, a boot console that discards its output, as - /// cold boots have, the control console at the launch's endpoint, which authenticates with - /// the capability that the VMM reads from its standard input, and the outcome report, - /// [`SandboxStore::outcome_path`](crate::host::store::SandboxStore::outcome_path). They name - /// no kernel, initramfs, kernel command line, or RAM, which the snapshot fixes. The VMM runs - /// in the sandbox's directory. + /// snapshot. The clone's export, if it has one, follows, bound to the template's dormant + /// filesystem slot: `--mount` with + /// [`GUEST_EXPORT`](crate::host::filesystem::GUEST_EXPORT), the exported directory, and its + /// mode, `rw` if a mapping writes it, and `ro` otherwise, then its hidden, re-exposed, and + /// writable paths, and the caller owner, as its [plan](ClonePlan::export) holds them; without + /// an export, the slot stays dormant. Then come `--single-process`, the template's backend, + /// CPU profile, and processors, `--restore-snapshot` with the directory of the generation's + /// snapshot, [`ClonePlan::snapshot`], `--restore-memory` with the memory target, + /// `--restore-ready-path` with the endpoint of the restore-ready event, a boot console that + /// discards its output, as cold boots have, the control console at the launch's endpoint, + /// which authenticates with the capability that the VMM reads from its standard input, and the + /// outcome report, [`SandboxStore::outcome_path`](crate::host::store::SandboxStore::outcome_path). + /// They name no kernel, initramfs, kernel command line, or RAM, which the snapshot fixes. The + /// VMM runs in the sandbox's directory. /// /// The claim of a scratch image passes to the VMM, which holds it until it exits. On Linux, /// the VMM inherits the claim's descriptor, which no argument names. On Windows, the claim's @@ -199,8 +204,17 @@ impl ClonePlan { /// not shorter than 108 bytes, as a Unix socket's address requires, or is not valid UTF-8, /// as OpenVMM and the host take it, or if the path of the control socket holds a comma or an /// equals sign, which OpenVMM's option `listen=` cannot take, - /// [`Evidence::EndpointBind`]; with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) - /// for an `openvmm` whose path cannot be resolved, the host's evidence; with + /// [`Evidence::EndpointBind`]; as + /// [`ExportPlan::verify`](crate::host::filesystem::ExportPlan::verify) fails for the clone's + /// export, if it has one: with [`ErrorKind::Other`](crate::ErrorKind::Other) for a + /// directory, or a mapped or hidden object that the guest could move, that is no longer the + /// object that the export's plan found, or that its path no longer reaches, and with + /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a read-only mapping that + /// the host cannot keep read-only, or an owner that the host no longer supports, + /// [`Evidence::InvalidMappings`], and with [`ErrorKind::Io`](crate::ErrorKind::Io) if an + /// object cannot be inspected otherwise, the host's evidence; with + /// [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) for an `openvmm` whose path + /// cannot be resolved, the host's evidence; with /// [`ErrorKind::Other`](crate::ErrorKind::Other) if the sandbox's path no longer names the /// directory that holds its lock, and if the progress log cannot be created, as when the /// path names a directory that holds one already, the host's evidence; @@ -266,10 +280,18 @@ impl ClonePlan { activation, capability, devices, + export, } = self; - let devices = device_arguments(&devices) + let mut devices = device_arguments(&devices) .attribute(Evidence::InvalidSettings) .map_err(unrecorded)?; + let table = match &export { + Some(export) => { + devices.extend(export.arguments()); + export.table() + } + None => Vec::new(), + }; let log = locked .open_dir() .and_then(|directory| ProgressLog::create(&directory, locked.dir(), lease.launch())) @@ -386,13 +408,15 @@ impl ClonePlan { record, capability, tracker, + table, }) } /// Refuses a launch of the plan through `lease` and `locked` that is not the one that the /// plan's lease reserves, or whose binding the sandbox does not record, one under `runtime` - /// that the template does not take, and one of a plan in RAM whose scratch device's file is - /// gone, as [`launch`](Self::launch) describes, without writing anything. + /// that the template does not take, one of a plan in RAM whose scratch device's file is gone, + /// and one of an export whose host objects changed since its plan found them, as + /// [`launch`](Self::launch) describes, without writing anything. fn check_launch( &self, lease: &Lease, @@ -473,6 +497,18 @@ impl ClonePlan { { self.check_sockets(socket, &locked.restore_ready_path())?; } + // The objects of the export must still be those that its plan found, before each restore, + // as before each cold start: a refusal is of the request's mappings, which no longer name + // what they named, as for an object that moved or is missing, and any other failure is the + // host's own. + if let Some(export) = &self.export { + export.plan().verify().map_err(|error| match error.kind() { + ErrorKind::Other | ErrorKind::Unsupported => { + Fault::new(Evidence::InvalidMappings, error) + } + _ => Fault::host(error), + })?; + } Ok(()) } @@ -711,6 +747,9 @@ pub struct Restoring<'s> { pub(super) capability: [u8; CAPABILITY_LEN], /// How far the clone got, and how its failures are recorded. pub(super) tracker: Tracker, + /// The clone's mapping table, which its restore sequence sends once the clone is ready: empty + /// for a clone without an export. + pub(super) table: Vec, } impl<'s> Restoring<'s> { diff --git a/nvx-rs/src/host/template/clone/reattach.rs b/nvx-rs/src/host/template/clone/reattach.rs index c11afa28b..95b4c4a33 100644 --- a/nvx-rs/src/host/template/clone/reattach.rs +++ b/nvx-rs/src/host/template/clone/reattach.rs @@ -81,8 +81,10 @@ pub enum ReattachedClone { /// sandbox's activation record, byte for byte, which must still be the canonical encoding of a /// record of the restore of the lease's generation: the clone answers with the Ready that it /// answered once, and the reattach checks the transaction ID that it reports, which -/// [`ReattachedClone::Live`] returns with the session. A check that fails, by the evidence that -/// the restore sequence gives it, ends the VMM through its verified identity, as +/// [`ReattachedClone::Live`] returns with the session. The clone's launch committed once the +/// clone applied its mapping table, if it has one, and started its sandbox, so the reattach +/// sends neither again, and no launch that it ends gets either. A check that fails, by the +/// evidence that the restore sequence gives it, ends the VMM through its verified identity, as /// [`Vmm::terminate`] does within `bound`, and the clone fails at [`Stage::Reattach`]. /// - A VMM that runs under another runtime identity, which might not speak the caller's /// protocol, is ended the same way, without a word to its guest, and the clone fails at diff --git a/nvx-rs/src/host/template/clone/record.rs b/nvx-rs/src/host/template/clone/record.rs index 9883b9b85..70f7c730e 100644 --- a/nvx-rs/src/host/template/clone/record.rs +++ b/nvx-rs/src/host/template/clone/record.rs @@ -3,10 +3,11 @@ //! clone's guest applies in place of the configuration that its template captured, as //! [`nvx::activation`](crate::activation) describes it. //! -//! A record names the bindings of the clone's image and scratch image by their identities: the -//! SHA-256 digest of a domain of each kind of binding and of the binding's serde form, which its -//! callers record. The digest covers a few hundred bytes of the binding's metadata, never the -//! content of a file. +//! A record names the bindings of the clone's image and scratch image, and the plan of its export, +//! by their identities: the SHA-256 digest of a domain of each kind of object and of the object's +//! serde form, which its callers record. The digest covers metadata alone, a few hundred bytes of +//! a binding, and the host paths and guest targets of an export plan, never the content of a +//! file. use serde::Serialize; use sha2::{Digest, Sha256}; @@ -14,10 +15,11 @@ use sha2::{Digest, Sha256}; use super::CloneRequest; use super::failure::{Attribute, Evidence, Fault}; use crate::activation::{ - ActivationRecord, Configuration, ExpectedResources, Identity, Image, Network, Publication, - RestoreSection, TransactionId, Upper, + ActivationRecord, Configuration, ExpectedResources, Export, ExportKind, Identity, Image, + Network, Publication, RestoreSection, TransactionId, Upper, }; use crate::error::{Error, Result}; +use crate::host::filesystem::ExportPlan; use crate::host::sandbox::distro::{DistroBinding, DistroFormat}; use crate::host::sandbox::scratch::ScratchBinding; use crate::host::template::key::TemplateKey; @@ -31,6 +33,10 @@ const DISTRO_DOMAIN: &[u8] = b"nvx distro binding\0"; /// [`DISTRO_DOMAIN`] does for an image's. const SCRATCH_DOMAIN: &[u8] = b"nvx scratch binding\0"; +/// What the identity of an export plan digests before the plan's serde form, as +/// [`DISTRO_DOMAIN`] does for an image's binding. +const EXPORT_DOMAIN: &[u8] = b"nvx export plan\0"; + /// Returns the activation record of the clone that `request` asks for, of the template whose key /// is `key`, which `lease` leases, with a fresh transaction ID. /// @@ -45,7 +51,9 @@ const SCRATCH_DOMAIN: &[u8] = b"nvx scratch binding\0"; /// - 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; -/// - no export, and so no mappings; +/// - the export, if the request has one: a directory, which stage 1's rule allows a clone alone, +/// with the identity of its plan, and the number of its mappings, the plan's binds, from 1 to +/// [`MAX_MAPPINGS`](crate::activation::MAX_MAPPINGS); or no export, and no mappings; /// - the network of the template's NIC, if it has one: the identity of the policy that OpenVMM /// enforces there, the digest that OpenVMM computed and the template's key records; the DNS /// server with which the template's guest configured its resolver at boot, which the template's @@ -62,8 +70,8 @@ const SCRATCH_DOMAIN: &[u8] = b"nvx scratch binding\0"; /// 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 /// [`TemplateKey::dns_server`] finds it, [`Evidence::MalformedGeneration`]; and with -/// [`ErrorKind::Other`](crate::ErrorKind::Other) if the transaction ID cannot be drawn, or a -/// binding's identity computed, the host's evidence. +/// [`ErrorKind::Other`](crate::ErrorKind::Other) if the transaction ID cannot be drawn, or the +/// identity of a binding or of the export's plan computed, the host's evidence. pub(super) fn build( request: &CloneRequest, lease: &Lease, @@ -99,12 +107,22 @@ pub(super) fn build( .network() .and_then(|network| network.proxy_url()), }); + let (export, mappings) = match &request.export { + None => (None, 0), + Some(plan) => ( + Some(Export { + kind: ExportKind::Directory, + plan: export_identity(plan).on_host()?, + }), + plan.binds.len(), + ), + }; let configuration = Configuration { image, upper, overlay: request.overlay, - export: None, - mappings: 0, + export, + mappings, network, workload: request.workload.clone(), resources: ExpectedResources { @@ -129,23 +147,31 @@ pub(super) fn build( /// 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 { - identity(DISTRO_DOMAIN, binding, "image") + identity(DISTRO_DOMAIN, binding, "binding of the clone's image") } /// Returns the identity of `binding`, the binding of a clone's scratch image, as the /// [module](self) describes it. pub(super) fn scratch_identity(binding: &ScratchBinding) -> Result { - identity(SCRATCH_DOMAIN, binding, "scratch image") + identity( + SCRATCH_DOMAIN, + binding, + "binding of the clone's scratch image", + ) +} + +/// Returns the identity of `plan`, the plan of a clone's export, as the [module](self) describes +/// it: it covers the export's whole shape, its directories and files, their policies, the binds, +/// and the owner. +pub(super) fn export_identity(plan: &ExportPlan) -> Result { + identity(EXPORT_DOMAIN, plan, "plan of the clone's export") } -/// Returns the SHA-256 digest of `domain` and of the serde form of `binding`, the binding of a -/// clone's file, which messages call `what`. -fn identity(domain: &[u8], binding: &impl Serialize, what: &str) -> Result { - let form = serde_json::to_vec(binding).map_err(|error| { - Error::other(format!( - "cannot write the binding of the clone's {what} in its serde form" - )) - .with_source(error) +/// Returns the SHA-256 digest of `domain` and of the serde form of `object`, a clone's binding or +/// export plan, which messages call `what`. +fn identity(domain: &[u8], object: &impl Serialize, what: &str) -> Result { + let form = serde_json::to_vec(object).map_err(|error| { + Error::other(format!("cannot write the {what} in its serde form")).with_source(error) })?; let digest = Sha256::new() .chain_update(domain) diff --git a/nvx-rs/src/host/template/clone/restore.rs b/nvx-rs/src/host/template/clone/restore.rs index 5387c403f..18df9397a 100644 --- a/nvx-rs/src/host/template/clone/restore.rs +++ b/nvx-rs/src/host/template/clone/restore.rs @@ -1,9 +1,9 @@ //! The restore sequence of a clone, from its launch, a [`Restoring`], to its sandbox's start, //! through the adapter's framing of the clone's control session, a [`CloneControl`]: -//! [`Restoring::activate`] activates the clone, [`Activated::start`] starts its sandbox, and -//! [`Started::commit`] commits its launch, as the documentation of [`Restoring::activate`] -//! describes, while [`Activated::fail`] and [`Started::fail`] fail the clone for a step of the -//! caller's own between them. +//! [`Restoring::activate`] activates the clone, [`Activated::start`] sends its mapping table, if +//! it has an export, then starts its sandbox, and [`Started::commit`] commits its launch, as the +//! documentation of [`Restoring::activate`] describes, while [`Activated::fail`] and +//! [`Started::fail`] fail the clone for a step of the caller's own between them. use std::fmt; use std::io; @@ -23,7 +23,7 @@ use crate::host::session::{self, SessionError}; use crate::host::store::Recovery; use crate::host::store::regular::{open_regular, read_at_most}; use crate::host::supervisor::{Starting, Vmm}; -use crate::protocol::CAPABILITY_LEN; +use crate::protocol::{CAPABILITY_LEN, HostMap}; /// The restore-ready event that OpenVMM writes to a restore's `--restore-ready-path` once the /// restored VM has started, which [`Restoring::activate`] waits for. @@ -40,9 +40,9 @@ pub const RESTORE_READY_EVENT: &[u8] = b"OPENVMM_RESTORE_READY_V1\n"; pub const RESTORE_TO_READY_TIMEOUT: Duration = Duration::from_secs(60); /// The default Ready-to-Start deadline of a clone's restore sequence: how long after the check of -/// the clone's Ready its sandbox may start, which takes a few of the guest's answers, as the CI -/// runs of [`RESTORE_TO_READY_TIMEOUT`] show. It is the test adapter's deadline of a session's -/// answers. +/// the clone's Ready its sandbox may start, which takes a few of the guest's answers, to the parts +/// of its mapping table and to the start, as the CI runs of [`RESTORE_TO_READY_TIMEOUT`] show. +/// It is the test adapter's deadline of a session's answers. pub const READY_TO_START_TIMEOUT: Duration = Duration::from_secs(30); /// The most sessions in which [`Restoring::activate`] sends a clone's Activate: its first session, @@ -116,9 +116,25 @@ pub trait CloneControl { deadline: Instant, ) -> Result, Self::Error>; - /// Starts the clone's sandbox over `session`, once the clone is ready: sends what the - /// framing sends before the start, such as the mappings that the record announces, then the - /// start, and waits until `deadline` for its confirmation. + /// Sends `table`, the clone's mapping table, which its record announces, over `session`, once + /// the clone is ready, as the framing sends a mapping table: in order, in as many requests as + /// it takes, each once the guest applied the one before; and waits until `deadline` for the + /// guest to apply every mapping. The restore sequence calls it only for a clone with an export, + /// whose table holds 1 to [`MAX_MAPPINGS`](crate::activation::MAX_MAPPINGS) mappings, as + /// [`ClonePlan::table`](super::ClonePlan::table) returns them, and never again in a session. + /// + /// Fails once the guest refused or failed a mapping, after which a guest starts no sandbox, as + /// does a guest that applied some of the table, and the framing sends no more of it, and once + /// the session failed: the sequence then ends the launch, and never starts the sandbox. + fn map( + &mut self, + session: &mut Self::Session, + table: &[HostMap], + deadline: Instant, + ) -> Result<(), Self::Error>; + + /// Starts the clone's sandbox over `session`, once the clone is ready, and applied its mapping + /// table if it has one: sends the start, and waits until `deadline` for its confirmation. fn start(&mut self, session: &mut Self::Session, deadline: Instant) -> Result<(), Self::Error>; /// Returns whether `error`, with which [`attach`](Self::attach) or @@ -127,6 +143,21 @@ pub trait CloneControl { /// guest's refusal or the passing of the deadline: a clone that answers no more in a lost /// session may still answer in another. fn lost(&self, error: &Self::Error) -> bool; + + /// Returns whether `error`, with which [`map`](Self::map) failed, is the guest's refusal of + /// the clone's mapping table: its answer that it would not, or could not, apply a part of the + /// table, or one of its mappings, to the clone's root, which the request's mappings and image + /// decide, rather than a failure of the session or of the framing. The restore sequence + /// attributes such a failure to the request's mappings, + /// [`Evidence::InvalidMappings`](super::Evidence::InvalidMappings), whenever it came, and any + /// other to what the host saw of it, as [`Activated::start`] describes. + /// + /// No failure is, by default: a framing whose answers do not tell the guest's refusal of a + /// table leaves the provenance of its failures unknown. + fn refused(&self, error: &Self::Error) -> bool { + let _ = error; + false + } } impl<'s> Restoring<'s> { @@ -165,9 +196,10 @@ impl<'s> Restoring<'s> { /// input; the Activate; and the Ready. /// /// Returns the [`Activated`] clone, whose Ready-to-Start deadline, `deadlines.start`, starts - /// at the check of its Ready, and whose sandbox [`Activated::start`] starts, after which - /// [`Started::commit`] commits the launch, as the cold boot of a sandbox commits its launch - /// once it checked its VM, with [`Starting::commit`]. + /// at the check of its Ready, and whose sandbox [`Activated::start`] starts, once the clone + /// applied its mapping table, if it has an export, after which [`Started::commit`] commits the + /// launch, as the cold boot of a sandbox commits its launch once it checked its VM, with + /// [`Starting::commit`]. /// /// # Retries /// @@ -237,6 +269,7 @@ impl<'s> Restoring<'s> { record, capability, mut tracker, + table, } = self; let Some(deadline) = launched.checked_add(deadlines.ready) else { let fault = beyond_clock("restore-to-Ready", deadlines.ready); @@ -279,14 +312,15 @@ impl<'s> Restoring<'s> { image, activation, tracker, + table, }) } } /// A clone that its restore sequence activated, as [`Restoring::activate`] returns it: its guest /// answered the Activate of its record with a Ready that reports the record's activation, and -/// awaits the start of its sandbox, which [`start`](Self::start) asks for, by the Ready-to-Start -/// deadline, [`deadline`](Self::deadline). +/// awaits its mapping table, if it has an export, and the start of its sandbox, which +/// [`start`](Self::start) sends, by the Ready-to-Start deadline, [`deadline`](Self::deadline). /// /// It holds the adapter's session in which the clone answered, and the launch, uncommitted, /// through which it borrows the sandbox's locked store exclusively, as [`Starting`] does. @@ -306,6 +340,9 @@ pub struct Activated<'s, S> { activation: ActivationRecord, /// How far the clone got, and how its failures are recorded. tracker: Tracker, + /// The clone's mapping table, which [`start`](Self::start) sends before the start: empty for + /// a clone without an export. + table: Vec, } impl<'s, S> Activated<'s, S> { @@ -339,16 +376,31 @@ impl<'s, S> Activated<'s, S> { /// clone, whose launch the caller commits. The progress log records that the start was sent, /// then that it was confirmed. /// - /// The start is not sent again: a guest that started its sandbox refuses another start, so a - /// session that fails once the start was sent leaves the start unknown. A failure ends the - /// launch, as [`abandon`](Self::abandon) does, and fails with a [`CloneFailure`] at - /// [`Stage::Start`], as [`Restoring::activate`] does: with + /// A clone with an export first gets its mapping table, [`ClonePlan::table`], which its + /// record announces: [`CloneControl::map`] sends it in order, and returns once the guest + /// applied every mapping. Only then does the progress log record that the start was sent, and + /// [`CloneControl::start`] send it, which a clone without an export gets at once. Neither is + /// sent again: a guest that applied some mappings starts no sandbox, and a guest that started + /// its sandbox refuses another start, so a session that fails once the table or the start was + /// sent leaves the clone's state unknown. + /// + /// A failure of either, a partial or failed mapping table included, ends the launch, as + /// [`abandon`](Self::abandon) does, and fails with a [`CloneFailure`] at [`Stage::Start`], as + /// [`Restoring::activate`] does. A failure of the table leaves the clone's progress at + /// [`Progress::Ready`], since the progress log records the start only once the table is + /// applied, so that its record tells it from a failure of the start, whose progress is + /// [`Progress::Starting`]. The failure's evidence is the guest's refusal of the table, + /// [`Evidence::InvalidMappings`], once the adapter's framing tells it, as + /// [`CloneControl::refused`] does; otherwise it fails with /// [`ErrorKind::TimedOut`](crate::ErrorKind::TimedOut) once the deadline passed, /// [`Evidence::Timeout`], and with [`ErrorKind::Protocol`](crate::ErrorKind::Protocol) /// before, [`Evidence::Reset`] for a session that the adapter lost and /// [`Evidence::Protocol`] otherwise, unless OpenVMM's outcome report of a VMM that exited /// gives evidence that prevails; and with the host's evidence if the progress log cannot be - /// appended to. + /// appended to. None of these is the template's, so no failure of the table or of the start + /// quarantines the template's generation. + /// + /// [`ClonePlan::table`]: super::ClonePlan::table pub fn start>( self, control: &mut C, @@ -360,52 +412,24 @@ impl<'s, S> Activated<'s, S> { image, activation, mut tracker, + table, } = self; - if let Err(fault) = tracker.reach(Progress::Starting) { - drop(session); - return Err(tracker.fail(starting, Stage::Start, fault)); - } - let started = control.start(&mut session, deadline); - let fault = match started { - Ok(()) => match tracker.reach(Progress::Started) { - Ok(()) => { - return Ok(Started { - session, - starting, - image, - activation, - tracker, - }); - } - Err(fault) => fault, - }, - Err(source) => { - let (evidence, error) = if Instant::now() >= deadline { - ( - Evidence::Timeout, - Error::timed_out( - "the clone's sandbox did not start by its Ready-to-Start deadline", - ), - ) - } else if control.lost(&source) { - ( - Evidence::Reset, - Error::protocol( - "the clone's control session was lost: cannot start the clone's \ - sandbox", - ), - ) - } else { - ( - Evidence::Protocol, - Error::protocol("cannot start the clone's sandbox"), - ) - }; - Fault::new(evidence, error.with_source(source)) + match order(control, &mut session, &mut tracker, &table, deadline) { + Ok(()) => Ok(Started { + session, + starting, + image, + activation, + tracker, + }), + // The one place where the order of a clone that is ready ends its launch: a mapping + // table that the guest did not apply in whole, a start that failed, or a progress log + // that could not record them. + Err(fault) => { + drop(session); + Err(tracker.fail(starting, Stage::Start, fault)) } - }; - drop(session); - Err(tracker.fail(starting, Stage::Start, fault)) + } } /// Ends the launch, as [`Starting::abandon`] does, which terminates its VMM and ends the @@ -419,7 +443,7 @@ impl<'s, S> Activated<'s, S> { } /// Fails the clone, once it is ready, for a step of the caller's own before the start of its - /// sandbox, such as one that maps a directory of the host into the clone, with `evidence`, + /// sandbox, such as one that prepares the host's side of the clone, with `evidence`, /// which attributes the failure as [the attribution of evidence](Evidence#attribution) /// describes, and `error`, and returns the failure: once the adapter's session has ended, it /// ends the launch as [`abandon`](Self::abandon) does, and records the failure at @@ -448,10 +472,93 @@ impl fmt::Debug for Activated<'_, S> { .field("deadline", &self.deadline) .field("image", &self.image) .field("activation", &self.activation) + .field("table", &self.table) .finish_non_exhaustive() } } +/// Runs the order of a clone that is ready, as [`Activated::start`] describes, over `session` of +/// `control`, by `deadline`: sends the mapping table, `table`, if it has a mapping, then records in +/// `tracker` that the start was sent, sends it, and records that it was confirmed. Returns the +/// fault of the step that failed, which ends the order. +fn order( + control: &mut C, + session: &mut C::Session, + tracker: &mut Tracker, + table: &[HostMap], + deadline: Instant, +) -> std::result::Result<(), Fault> { + if !table.is_empty() { + control + .map(session, table, deadline) + .map_err(|source| Step::Map.fault(control, source, deadline))?; + } + tracker.reach(Progress::Starting)?; + control + .start(session, deadline) + .map_err(|source| Step::Start.fault(control, source, deadline))?; + tracker.reach(Progress::Started) +} + +/// A step of the order of a clone that is ready, which [`Activated::start`] takes in turn. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Step { + /// The mapping table, which [`CloneControl::map`] sends. + Map, + /// The start, which [`CloneControl::start`] sends. + Start, +} + +impl Step { + /// Returns the fault of the step's failure with `source`, which `control` returned, by the + /// Ready-to-Start deadline `deadline`, as [`Activated::start`] attributes it: the guest's + /// refusal of the mapping table is the request's mappings, whenever it came, and any other + /// failure is what the host saw of it, the passing of the deadline, a lost session, or a + /// broken protocol. + fn fault(self, control: &C, source: C::Error, deadline: Instant) -> Fault { + let (evidence, error) = if self == Self::Map && control.refused(&source) { + ( + Evidence::InvalidMappings, + Error::protocol("the clone refused its mapping table"), + ) + } else if Instant::now() >= deadline { + ( + Evidence::Timeout, + Error::timed_out(match self { + Self::Map => { + "the clone did not apply its mapping table by its Ready-to-Start deadline" + } + Self::Start => { + "the clone's sandbox did not start by its Ready-to-Start deadline" + } + }), + ) + } else if control.lost(&source) { + ( + Evidence::Reset, + Error::protocol(match self { + Self::Map => { + "the clone's control session was lost: the clone did not apply its \ + mapping table" + } + Self::Start => { + "the clone's control session was lost: cannot start the clone's sandbox" + } + }), + ) + } else { + ( + Evidence::Protocol, + Error::protocol(match self { + Self::Map => "the clone did not apply its mapping table", + Self::Start => "cannot start the clone's sandbox", + }), + ) + }; + Fault::new(evidence, error.with_source(source)) + } +} + /// A clone whose sandbox started, as [`Activated::start`] returns it, whose launch the caller /// [commits](Self::commit), once it checked anything else that it requires of a launch, or /// [abandons](Self::abandon), or [fails](Self::fail), if a check of its own failed. diff --git a/nvx-rs/src/host/template/clone/tests.rs b/nvx-rs/src/host/template/clone/tests.rs index 306b7d542..35b2f2610 100644 --- a/nvx-rs/src/host/template/clone/tests.rs +++ b/nvx-rs/src/host/template/clone/tests.rs @@ -1,7 +1,7 @@ -//! Tests of the plans of clones: the checks of their images, uppers, and memory targets against -//! the generation that they lease, which read no content, the claims of their scratch images, and -//! the sparse scratch device of an upper in RAM; in [`launch`], of their launches; and in -//! [`restore`], of their restore sequences. +//! Tests of the plans of clones: the checks of their images, uppers, exports, and memory targets +//! against the generation that they lease, which read no content, the claims of their scratch +//! images, and the sparse scratch device of an upper in RAM; in [`launch`], of their launches; and +//! in [`restore`], of their restore sequences. mod launch; mod restore; @@ -15,8 +15,8 @@ use sha2::{Digest, Sha256}; use super::*; use crate::ErrorCode; use crate::activation::{ - Configuration, ExpectedResources, Identity, Image, MachineIdMode, Network, Publication, Upper, - WorkloadIdentity, + Configuration, ExpectedResources, Export, ExportKind, Identity, Image, MachineIdMode, Network, + Publication, Upper, WorkloadIdentity, }; use crate::host::filesystem; use crate::host::network::{Access as NetworkAccess, NetworkPolicy, NetworkProxy, ProxyScheme}; @@ -203,6 +203,7 @@ fn tmpfs_request(image: &Path, binding: &DistroBinding) -> CloneRequest { binding: binding.clone(), policy_revision: DISTRO_POLICY_REVISION, scratch: None, + export: None, memory: MEMORY, workload: workload(), overlay: OverlayMode::UntrustedLowers, @@ -256,6 +257,63 @@ fn network(access: NetworkAccess) -> SandboxNetwork { SandboxNetwork::new("10.0.0.2/24", NetworkPolicy::new(access)).unwrap() } +/// Returns the request `request` with the export `export`. +fn with_export(request: CloneRequest, export: &ExportPlan) -> CloneRequest { + CloneRequest { + export: Some(export.clone()), + ..request + } +} + +/// Plans the export of `mappings`, host paths each at its guest target, read-only or not, for the +/// container root, as an adapter's sandbox plans it, with the paths `denied` hidden, and the paths +/// `allowed` inside them shown again. +fn export_plan( + mappings: &[(&Path, &str, bool)], + denied: &[PathBuf], + allowed: &[PathBuf], +) -> ExportPlan { + let mappings: Vec> = mappings + .iter() + .map(|&(path, target, read_only)| filesystem::Mapping { + name: "mappings", + path, + target: filesystem::Target::Guest(target), + read_only, + }) + .collect(); + let request = filesystem::ExportRequest { + denied: filesystem::PathList { + name: "denied", + paths: denied, + }, + allowed: filesystem::PathList { + name: "allowed", + paths: allowed, + }, + ..filesystem::ExportRequest::new(&mappings, filesystem::ReservedTargets::CONTAINER_ROOT) + }; + filesystem::plan(&request).unwrap().unwrap() +} + +/// Returns the mapping table of these tests' exports, [`Fixture::data_export`]: the exported +/// directory itself, the export's root, which an empty source names, and the directory `work` in +/// it, by its path there, without the index of a child. +fn data_table() -> [HostMap; 2] { + [ + HostMap { + source: String::new(), + target: "/data".to_owned(), + read_only: true, + }, + HostMap { + source: "work".to_owned(), + target: "/work".to_owned(), + read_only: false, + }, + ] +} + /// Returns the kind and the message of the error of `result`, a clone's plan that failed with /// `evidence`, as [`refused_at`] checks it. fn refused( @@ -397,6 +455,29 @@ impl Fixture { (path, binding) } + /// Creates the directory `name` among the exported directories, with the directories `inside` + /// it, and returns its path, with its links resolved, as an export plan names it. + fn exported(&self, name: &str, inside: &[&str]) -> PathBuf { + let dir = self.path("exports", name); + fs::create_dir_all(&dir).unwrap(); + for path in inside { + fs::create_dir_all(dir.join(path)).unwrap(); + } + filesystem::canonicalize(&dir).unwrap() + } + + /// Plans the export of the exported directory `name`, which the guest reads at `/data`, and + /// in which it writes the directory `work`, at `/work`, as [`data_table`] maps them: one + /// directory, whose writes the plan narrows to `work`. + fn data_export(&self, name: &str) -> ExportPlan { + let data = self.exported(name, &["work"]); + export_plan( + &[(&data, "/data", true), (&data.join("work"), "/work", false)], + &[], + &[], + ) + } + /// Provisions the sandbox `name`, and returns its directory, with its links resolved, as a /// lease names it. fn provision(&self, name: &str) -> PathBuf { @@ -2432,3 +2513,213 @@ fn only_a_path_that_no_longer_names_its_supplied_file_is_the_request_s() { fault.error ); } + +#[test] +fn a_clone_with_an_export_records_its_plan_and_maps_from_its_directory() { + let fixture = Fixture::new(); + let (image, binding) = fixture.image("distro.erofs", IMAGE_LEN); + let mut acquired = fixture.acquire("sandbox"); + let export = fixture.data_export("data"); + let request = with_export(tmpfs_request(&image, &binding), &export); + let plan = fixture.plan(&mut acquired, &request, NO_CONTENT).unwrap(); + assert_eq!(plan.export(), Some(&export)); + // The record names the export, a directory, by the identity of its plan, and announces its + // mappings, the plan's binds. + let configuration = plan.activation().configuration(); + assert_eq!( + configuration.export, + Some(Export { + kind: ExportKind::Directory, + plan: digest_of(b"nvx export plan\0", &export), + }) + ); + assert_eq!(configuration.mappings, export.binds.len()); + // The directory is the export's root, so the table's sources name paths in it, without the + // child's index that an aggregate's table holds. + assert_eq!(plan.table(), data_table()); + let aggregate: Vec = export + .guest_table() + .into_iter() + .map(|mapping| mapping.source) + .collect(); + assert_eq!(aggregate, ["0", "0/work"]); + // The identity covers the whole plan: another directory's is another. + drop(plan); + let other = fixture.data_export("other"); + let request = with_export(tmpfs_request(&image, &binding), &other); + let plan = fixture.plan(&mut acquired, &request, NO_CONTENT).unwrap(); + let recorded = plan.activation().configuration().export.unwrap(); + assert_eq!(recorded.plan, digest_of(b"nvx export plan\0", &other)); + assert_ne!(recorded.plan, digest_of(b"nvx export plan\0", &export)); + // A clone without mappings has no export, and its record none, so its template's slot stays + // dormant. + drop(plan); + let request = tmpfs_request(&image, &binding); + let plan = fixture.plan(&mut acquired, &request, NO_CONTENT).unwrap(); + assert_eq!(plan.export(), None); + assert!(plan.table().is_empty()); + let configuration = plan.activation().configuration(); + assert_eq!((&configuration.export, configuration.mappings), (&None, 0)); +} + +#[test] +fn exports_that_a_dormant_filesystem_slot_does_not_bind_are_refused_before_anything_is_opened() { + let fixture = Fixture::new(); + let (image, binding) = fixture.image("distro.erofs", IMAGE_LEN); + let mut acquired = fixture.acquire("sandbox"); + let request = tmpfs_request(&image, &binding); + let mut refusal = |export: &ExportPlan| { + let request = with_export(request.clone(), export); + let plan = fixture.plan(&mut acquired, &request, NO_CONTENT); + let (kind, message) = refused(plan, Evidence::InvalidMappings); + assert_eq!(kind, ErrorKind::Unsupported, "{message}"); + message + }; + // Two directories, which only an aggregate exports. + let one = fixture.exported("one", &[]); + let two = fixture.exported("two", &[]); + let both = export_plan(&[(&one, "/one", false), (&two, "/two", true)], &[], &[]); + assert_eq!(both.children.len(), 2); + assert_eq!( + refusal(&both), + "the export of a clone binds one directory to its template's dormant filesystem slot, \ + but its export plan exports 2 directories and files" + ); + // A file, which OpenVMM exports alone only in an aggregate. + let file = fixture.path("exports", "file.txt"); + fs::write(&file, b"file").unwrap(); + let file = filesystem::canonicalize(&file).unwrap(); + let alone = export_plan(&[(&file, "/file.txt", true)], &[], &[]); + assert!(alone.children[0].file); + assert_eq!( + refusal(&alone), + format!( + "the export of a clone binds one directory to its template's dormant filesystem \ + slot, but its export plan exports the file {}", + file.display() + ) + ); + // Mappings without an export, and an export without mappings. + let export = fixture.data_export("data"); + let unexported = ExportPlan { + children: Vec::new(), + ..export.clone() + }; + assert_eq!( + refusal(&unexported), + "the recorded export plan is invalid: it exports 0 directories and files, but OpenVMM \ + exports from 1 to 256" + ); + let unmapped = ExportPlan { + binds: Vec::new(), + ..export.clone() + }; + assert_eq!( + refusal(&unmapped), + "the recorded export plan is invalid: it maps 0 paths, but a sandbox maps from 1 to 4096" + ); + // A plan that maps a target that the container root keeps for itself. + let mut reserved = export.clone(); + reserved.binds[1].target = "/proc/work".to_owned(); + let message = refusal(&reserved); + assert!( + message.starts_with("the recorded export plan is invalid"), + "{message}" + ); + // None of them opened anything, and the plan of one directory serves a clone. + assert_untouched(&acquired); + let request = with_export(request, &export); + drop(fixture.plan(&mut acquired, &request, NO_CONTENT).unwrap()); +} + +#[test] +fn exports_that_overlap_the_template_store_are_refused_before_anything_is_opened() { + // The plan compares the paths that the export's plan resolved with the store's, as the store + // names it, without reading anything: this store's path is resolved. + let fixture = Fixture::in_store( + |directory| { + filesystem::canonicalize(directory) + .unwrap() + .join("templates") + }, + |_, _, _| {}, + ); + let (image, binding) = fixture.image("distro.erofs", IMAGE_LEN); + let mut acquired = fixture.acquire("sandbox"); + let request = tmpfs_request(&image, &binding); + let store = fixture.store.dir().to_owned(); + let payload = fixture.store.payload_dir(fixture.generation.generation()); + // The store's directory, the payload of a generation inside it, and a directory that holds + // the store, which a guest could reach the payload through, and write it if writable. + for (dir, read_only) in [ + (store.clone(), true), + (payload, false), + (fixture.directory.path().to_owned(), true), + ] { + let export = export_plan(&[(&dir, "/data", read_only)], &[], &[]); + let request = with_export(request.clone(), &export); + let plan = fixture.plan(&mut acquired, &request, NO_CONTENT); + assert_eq!( + refused(plan, Evidence::InvalidMappings), + ( + ErrorKind::Unsupported, + format!( + "the export of a clone cannot share {}, which overlaps the template store {}, \ + whose leased payloads nothing may change", + export.children[0].root.display(), + store.display() + ) + ) + ); + assert_untouched(&acquired); + } + // A directory beside the store is no part of it. + let export = fixture.data_export("data"); + let request = with_export(request, &export); + drop(fixture.plan(&mut acquired, &request, NO_CONTENT).unwrap()); +} + +#[test] +fn the_export_of_a_clone_binds_its_directory_with_its_policy_and_owner() { + let fixture = Fixture::new(); + let data = fixture.exported("data", &["work", "secret/shown"]); + let (work, secret) = (data.join("work"), data.join("secret")); + let shown = secret.join("shown"); + let mount = |mode: &str| format!("{},{},{mode}", filesystem::GUEST_EXPORT, data.display()); + let text = |path: &Path| path.to_str().unwrap().to_owned(); + // A directory that the guest reads, but for the directory that it writes in it, and that + // hides a directory, but for a directory in it: the guest writes only `work`. + let mut export = export_plan( + &[(&data, "/data", true), (&work, "/work", false)], + std::slice::from_ref(&secret), + std::slice::from_ref(&shown), + ); + let directory = DirectoryExport::new(&export).unwrap(); + assert_eq!(directory.plan(), &export); + assert_eq!( + directory.arguments(), + [ + "--mount".to_owned(), + mount("rw"), + "--mount-deny".to_owned(), + text(&secret), + "--mount-allow".to_owned(), + text(&shown), + "--mount-write".to_owned(), + text(&work), + ] + ); + // An export whose operations run as the guest's caller says so last. + export.owner = filesystem::Owner::Caller; + let arguments = DirectoryExport::new(&export).unwrap().arguments(); + assert_eq!( + arguments[arguments.len() - 2..], + ["--mount-owner", "caller"] + ); + // A directory that the guest only reads is bound read-only. + let read_only = export_plan(&[(&data, "/data", true)], &[], &[]); + assert_eq!( + DirectoryExport::new(&read_only).unwrap().arguments(), + ["--mount".to_owned(), mount("ro")] + ); +} diff --git a/nvx-rs/src/host/template/clone/tests/launch.rs b/nvx-rs/src/host/template/clone/tests/launch.rs index 1cde8a7d1..b35cd817a 100644 --- a/nvx-rs/src/host/template/clone/tests/launch.rs +++ b/nvx-rs/src/host/template/clone/tests/launch.rs @@ -1105,6 +1105,189 @@ fn a_clone_on_a_scratch_image_passes_its_claim_to_its_vmm() { assert_eq!(restoring.abandon().unwrap(), Recovery::Exited); } +#[test] +fn a_clone_with_an_export_binds_it_to_its_template_s_dormant_filesystem_slot() { + let fixture = Fixture::new(); + complete_snapshot(&fixture); + let (image, binding) = fixture.image("distro.erofs", IMAGE_LEN); + let mut acquired = fixture.acquire("sandbox"); + let export = fixture.data_export("data"); + let request = with_export(tmpfs_request(&image, &binding), &export); + let plan = fixture.plan(&mut acquired, &request, NO_CONTENT).unwrap(); + let runtime = plan.key().runtime().clone(); + let mut vmm = VmmDir::new(); + let dir = vmm.path().to_owned(); + let made = RefCell::new(None); + let command = fake_vmm(&dir, &made); + let hooks = Hooks { + command: &command, + ..Hooks::LAUNCH + }; + let restoring = plan + .launch_with(&mut acquired, Path::new(OPENVMM), &runtime, hooks) + .unwrap(); + let reference = restoring.starting().process().reference().clone(); + vmm.reference = Some(reference.clone()); + vmm.started(); + // The export follows the clone's block devices: its directory on the dormant slot, writable, + // since the guest writes a directory in it, to which the export narrows the guest's writes. + let data = &export.children[0].root; + let mount = format!("{},{},rw", filesystem::GUEST_EXPORT, data.display()); + let work = data.join("work"); + let arguments = made.take().unwrap().arguments; + let devices = arguments + .iter() + .position(|argument| argument == "--single-process") + .unwrap(); + assert_eq!( + arguments[6..devices], + [ + "--mount", + mount.as_str(), + "--mount-write", + work.to_str().unwrap() + ] + ); + // The restore sequence sends the export's table once the clone is ready. + assert_eq!(restoring.table, data_table()); + assert_eq!( + restoring.abandon().unwrap(), + Recovery::Terminated(reference) + ); +} + +#[test] +fn a_clone_whose_exported_objects_changed_since_its_plan_starts_nothing() { + let fixture = Fixture::new(); + complete_snapshot(&fixture); + let (image, binding) = fixture.image("distro.erofs", IMAGE_LEN); + let request = tmpfs_request(&image, &binding); + let runtime = runtime_of(&fixture); + let hooks = Hooks { + command: &never_spawned, + ..Hooks::LAUNCH + }; + let changed = |path: &Path| { + ( + ErrorKind::Other, + format!( + "{} no longer names the host object it named at provision; deprovision the \ + sandbox and provision it again", + path.display() + ), + ) + }; + // A directory that the guest writes, in which it maps a directory that a workload could + // move, which another directory has replaced since the plan. + let mut moved = fixture.acquire("moved"); + let data = fixture.exported("moved", &["inner"]); + let inner = data.join("inner"); + let export = export_plan( + &[(&data, "/data", false), (&inner, "/inner", false)], + &[], + &[], + ); + assert!(export.binds[1].identity.is_some()); + let plan = fixture + .plan( + &mut moved, + &with_export(request.clone(), &export), + NO_CONTENT, + ) + .unwrap(); + fs::rename(&inner, data.join("renamed")).unwrap(); + fs::create_dir(&inner).unwrap(); + assert_eq!( + refused_at( + plan.launch_with(&mut moved, Path::new(OPENVMM), &runtime, hooks), + Stage::Launch, + Evidence::InvalidMappings, + ), + changed(&inner) + ); + assert_unlaunched(&fixture, &moved); + // The exported directory, which is missing since the plan. + let mut missing = fixture.acquire("missing"); + let export = fixture.data_export("missing"); + let data = export.children[0].root.clone(); + let plan = fixture + .plan( + &mut missing, + &with_export(request.clone(), &export), + NO_CONTENT, + ) + .unwrap(); + fs::remove_dir_all(&data).unwrap(); + assert_eq!( + refused_at( + plan.launch_with(&mut missing, Path::new(OPENVMM), &runtime, hooks), + Stage::Launch, + Evidence::InvalidMappings, + ), + changed(&data) + ); + assert_unlaunched(&fixture, &missing); + // The exported directory, which a file has replaced since the plan. + let mut replaced = fixture.acquire("replaced"); + let export = fixture.data_export("replaced"); + let data = export.children[0].root.clone(); + let plan = fixture + .plan(&mut replaced, &with_export(request, &export), NO_CONTENT) + .unwrap(); + fs::remove_dir_all(&data).unwrap(); + fs::write(&data, b"file").unwrap(); + assert_eq!( + refused_at( + plan.launch_with(&mut replaced, Path::new(OPENVMM), &runtime, hooks), + Stage::Launch, + Evidence::InvalidMappings, + ), + changed(&data) + ); + assert_unlaunched(&fixture, &replaced); +} + +#[test] +fn a_clone_whose_export_cannot_be_inspected_starts_nothing_and_fails_with_the_host_s_evidence() { + let fixture = Fixture::new(); + complete_snapshot(&fixture); + let (image, binding) = fixture.image("distro.erofs", IMAGE_LEN); + let request = tmpfs_request(&image, &binding); + let runtime = runtime_of(&fixture); + let hooks = Hooks { + command: &never_spawned, + ..Hooks::LAUNCH + }; + // The plan pins an object at a name that the host's file system cannot hold: the failure to + // inspect it shows nothing of the object, so it is the host's own, not the request's. + let mut acquired = fixture.acquire("uninspected"); + let data = fixture.exported("uninspected", &["inner"]); + let inner = data.join("inner"); + let mut export = export_plan( + &[(&data, "/data", false), (&inner, "/inner", false)], + &[], + &[], + ); + assert!(export.binds[1].identity.is_some()); + export.binds[1].source = "n".repeat(300); + let unnamed = export.children[0].root.join(&export.binds[1].source); + let plan = fixture + .plan(&mut acquired, &with_export(request, &export), NO_CONTENT) + .unwrap(); + assert_eq!( + refused_at( + plan.launch_with(&mut acquired, Path::new(OPENVMM), &runtime, hooks), + Stage::Launch, + Evidence::HostFailure, + ), + ( + ErrorKind::Io, + format!("cannot inspect {}", unnamed.display()) + ) + ); + assert_unlaunched(&fixture, &acquired); +} + #[test] fn a_clone_launches_only_as_the_launch_that_its_durable_binding_reserves() { let fixture = Fixture::new(); @@ -1653,6 +1836,16 @@ impl CloneControl for RoleControl<'_> { Ok(Some(ReadyActivation::of(&record))) } + fn map( + &mut self, + _session: &mut Box, + _table: &[HostMap], + _deadline: Instant, + ) -> io::Result<()> { + // The role's clone has no export, so its sequence sends no mapping table. + unreachable!("a clone without an export got a mapping table") + } + fn start(&mut self, _session: &mut Box, _deadline: Instant) -> io::Result<()> { if self.step == "starting" { crash(self.step); @@ -2133,6 +2326,15 @@ impl CloneControl for ReattachControl { Ok((self.answer)(ReadyActivation::of(&record))) } + fn map( + &mut self, + _session: &mut Box, + _table: &[HostMap], + _deadline: Instant, + ) -> io::Result<()> { + panic!("a reattach sends no mapping table") + } + fn start(&mut self, _session: &mut Box, _deadline: Instant) -> io::Result<()> { panic!("a reattach starts no sandbox") } diff --git a/nvx-rs/src/host/template/clone/tests/restore.rs b/nvx-rs/src/host/template/clone/tests/restore.rs index 753b735d8..b23972635 100644 --- a/nvx-rs/src/host/template/clone/tests/restore.rs +++ b/nvx-rs/src/host/template/clone/tests/restore.rs @@ -3,10 +3,11 @@ //! sends its restore-ready event as its plan says, and a [`FakeControl`] in place of the adapter's //! framing: the wait for the event, and the connection only once it came; the Activate of the //! sandbox's record, byte for byte, as the first request of each session; the check of the -//! activation that Ready reports; the start; the sessions that replace those that the adapter lost, -//! after a reset of the session, say, and the clone that ended once it took that reset before it -//! was ready; and the deadlines. The crashes of the sequence's caller are among the launch's -//! tests. +//! activation that Ready reports; the mapping table of a clone with an export, and the end of the +//! launch at a partial or failed table; the start; the sessions that replace those that the +//! adapter lost, after a reset of the session, say, and the clone that ended once it took that +//! reset before it was ready; and the deadlines. The crashes of the sequence's caller are among +//! the launch's tests. use std::cell::RefCell; use std::collections::VecDeque; @@ -52,6 +53,12 @@ enum Call { record: Vec, deadline: Instant, }, + /// A mapping table in a session, by the deadline. + Map { + session: usize, + table: Vec, + deadline: Instant, + }, /// A start in a session, by the deadline. Start { session: usize, deadline: Instant }, } @@ -69,24 +76,41 @@ enum Answer { Silent, } -/// A failure of a [`FakeControl`]: one that lost its session, or another. +/// A failure of a [`FakeControl`]: one that lost its session, the guest's refusal of a mapping +/// table, or another. #[derive(Debug)] struct Failure { /// Whether the failure lost the session. lost: bool, + /// Whether the failure is the guest's refusal of a mapping table, which the framing tells. + table: bool, } impl Failure { /// The loss of the session, as its reset or the end of its stream are. - const LOST: Self = Self { lost: true }; + const LOST: Self = Self { + lost: true, + table: false, + }; /// Another failure, which keeps the session, as the guest's refusal does. - const REFUSED: Self = Self { lost: false }; + const REFUSED: Self = Self { + lost: false, + table: false, + }; + /// The guest's refusal of a mapping table, which keeps the session, and which the framing + /// tells, as [`CloneControl::refused`] does. + const TABLE: Self = Self { + lost: false, + table: true, + }; } impl fmt::Display for Failure { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(if self.lost { "the fake framing lost its session" + } else if self.table { + "the fake guest refused its mapping table" } else { "the fake guest refused" }) @@ -104,10 +128,10 @@ struct FakeSession { } /// The adapter's framing of the tests: it attaches a session to each connection that the sequence -/// makes, numbered from 1, answers each Activate as the next of its `activates` says, and each -/// start as the next of its `starts` says, as [`Answer::Done`] does once they run out, and records -/// each call. Its `on_activate` runs at each Activate, with the session's number, before the -/// answer. +/// makes, numbered from 1, answers each Activate as the next of its `activates` says, each mapping +/// table as the next of its `maps` says, and each start as the next of its `starts` says, as +/// [`Answer::Done`] does once they run out, and records each call. Its `on_activate` runs at each +/// Activate, with the session's number, before the answer. #[derive(Default)] struct FakeControl<'a> { /// The calls, in order. @@ -116,6 +140,11 @@ struct FakeControl<'a> { sessions: usize, /// How it answers the next Activates. activates: VecDeque, + /// How its guest answers the next mapping tables: how many of a table's mappings it applies, + /// in order, at most all, and its answer. + maps: VecDeque<(usize, Answer)>, + /// The mappings that its guest applied, in order. + applied: Vec, /// How it answers the next starts. starts: VecDeque, /// What runs at each Activate. @@ -148,13 +177,13 @@ impl<'a> FakeControl<'a> { .iter() .filter_map(|call| match call { Call::Attach { deadline, .. } | Call::Activate { deadline, .. } => Some(*deadline), - Call::Start { .. } => None, + Call::Map { .. } | Call::Start { .. } => None, }) .collect() } /// Checks that each session of the framing attached, then sent an Activate first, in its - /// order, and that the starts came last, in the last session. + /// order, and that the mapping tables and the starts came last, in the last session. fn assert_activates_first(&self) { let mut session = 0; let mut pending = false; @@ -174,7 +203,10 @@ impl<'a> FakeControl<'a> { assert!(pending && *number == session, "{:?}", self.calls); pending = false; } - Call::Start { + Call::Map { + session: number, .. + } + | Call::Start { session: number, .. } => { assert!(!pending && *number == session, "{:?}", self.calls); @@ -242,6 +274,23 @@ impl CloneControl for FakeControl<'_> { answered(answer, Some(ready), deadline) } + fn map( + &mut self, + session: &mut FakeSession, + table: &[HostMap], + deadline: Instant, + ) -> Result<(), Failure> { + self.calls.push(Call::Map { + session: session.number, + table: table.to_vec(), + deadline, + }); + let (applied, answer) = self.maps.pop_front().unwrap_or((table.len(), Answer::Done)); + self.applied + .extend_from_slice(&table[..applied.min(table.len())]); + answered(answer, Confirmed, deadline).map(|_| ()) + } + fn start(&mut self, session: &mut FakeSession, deadline: Instant) -> Result<(), Failure> { self.calls.push(Call::Start { session: session.number, @@ -254,6 +303,10 @@ impl CloneControl for FakeControl<'_> { fn lost(&self, error: &Failure) -> bool { error.lost } + + fn refused(&self, error: &Failure) -> bool { + error.table + } } /// The confirmation of a start, which [`answered`] returns. @@ -304,7 +357,19 @@ fn launch_ending<'a>( end: End, ) -> (Restoring<'a>, Launched) { let request = tmpfs_request(&image.0, &image.1); - let plan = fixture.plan(acquired, &request, NO_CONTENT).unwrap(); + launch_request(fixture, acquired, &request, vmm, end) +} + +/// Launches the clone of `acquired` as [`launch`] does, as `request` asks, whose failures end the +/// launch with `end`. +fn launch_request<'a>( + fixture: &Fixture, + acquired: &'a mut Acquired, + request: &CloneRequest, + vmm: &mut VmmDir, + end: End, +) -> (Restoring<'a>, Launched) { + let plan = fixture.plan(acquired, request, NO_CONTENT).unwrap(); let runtime = plan.key().runtime().clone(); let dir = vmm.path().to_owned(); let made = RefCell::new(None); @@ -1318,3 +1383,239 @@ fn a_diagnosis_too_long_for_a_record_keeps_its_ends_and_its_evidence_through_a_r [record.template.generation] ); } + +#[test] +fn a_clone_with_an_export_gets_its_mapping_table_once_ready_and_its_start_once_it_applied_it() { + let (fixture, image) = fixture(); + let export = fixture.data_export("data"); + let request = with_export(tmpfs_request(&image.0, &image.1), &export); + let mut acquired = fixture.acquire("exported"); + let mut vmm = vmm_with("flush"); + let (restoring, _) = launch_request( + &fixture, + &mut acquired, + &request, + &mut vmm, + Hooks::LAUNCH.end, + ); + let mut control = FakeControl::default(); + let activated = restoring + .activate(&mut control, RestoreDeadlines::default()) + .unwrap(); + let deadline = activated.deadline(); + let started = activated.start(&mut control).unwrap(); + // In the session in which the clone answered, the table follows its Ready, in order, and the + // start follows once the guest applied every mapping, both by the Ready-to-Start deadline. + control.assert_activates_first(); + assert_eq!( + control.calls[2..], + [ + Call::Map { + session: 1, + table: data_table().to_vec(), + deadline, + }, + Call::Start { + session: 1, + deadline, + }, + ] + ); + assert_eq!(control.applied, data_table()); + let (running, _) = started.commit().unwrap(); + let locked = &mut acquired.locked; + assert_eq!( + running.terminate(locked, TIMEOUT).unwrap(), + StopOutcome::Forced + ); + assert_eq!( + fixture.store.release(locked).unwrap(), + Some(acquired.lease.clone()) + ); + + // A clone without an export gets its start at once. + let mut acquired = fixture.acquire("plain"); + let mut vmm = vmm_with("flush"); + let (restoring, _) = launch(&fixture, &mut acquired, &image, &mut vmm); + assert!(restoring.table.is_empty()); + let mut control = FakeControl::default(); + let activated = restoring + .activate(&mut control, RestoreDeadlines::default()) + .unwrap(); + let deadline = activated.deadline(); + let started = activated.start(&mut control).unwrap(); + assert_eq!( + control.calls[2..], + [Call::Start { + session: 1, + deadline, + }] + ); + assert!(control.applied.is_empty()); + drop(started.abandon().unwrap()); + assert_launch_ended(&fixture, &mut acquired, &vmm); +} + +#[test] +fn a_partial_or_failed_mapping_table_ends_the_launch_and_its_sandbox_never_starts() { + let (fixture, image) = fixture(); + let export = fixture.data_export("data"); + let request = with_export(tmpfs_request(&image.0, &image.1), &export); + let deadlines = RestoreDeadlines { + start: SHORT, + ..RestoreDeadlines::default() + }; + let refused = "the clone refused its mapping table"; + let failed = "the clone did not apply its mapping table"; + let lost = "the clone's control session was lost: the clone did not apply its mapping table"; + let late = "the clone did not apply its mapping table by its Ready-to-Start deadline"; + // A guest that refuses the table once it applied some of it, or none of it, as the request's + // mappings decide; a failure of the framing that tells no refusal; a session lost once the + // table was sent; and a guest that never answers. + let cases = [ + ( + "partial", + 1, + Answer::Fail(Failure::TABLE), + (ErrorKind::Protocol, refused), + Evidence::InvalidMappings, + ), + ( + "refused", + 0, + Answer::Fail(Failure::TABLE), + (ErrorKind::Protocol, refused), + Evidence::InvalidMappings, + ), + ( + "failed", + 1, + Answer::Fail(Failure::REFUSED), + (ErrorKind::Protocol, failed), + Evidence::Protocol, + ), + ( + "lost", + 1, + Answer::Fail(Failure::LOST), + (ErrorKind::Protocol, lost), + Evidence::Reset, + ), + ( + "silent", + 0, + Answer::Silent, + (ErrorKind::TimedOut, late), + Evidence::Timeout, + ), + ]; + // The progress log records the start only once the guest applied the whole table, so each + // failure is recorded at the start's stage, with the clone's progress still at its Ready. + let ready = (Stage::Start, Progress::Ready); + for (name, applied, answer, (kind, message), evidence) in cases { + let mut acquired = fixture.acquire(name); + let mut vmm = vmm_with("flush"); + let (restoring, _) = launch_request( + &fixture, + &mut acquired, + &request, + &mut vmm, + Hooks::LAUNCH.end, + ); + let mut control = FakeControl::default(); + let source = match &answer { + Answer::Fail(failure) => failure.to_string(), + _ => Failure::REFUSED.to_string(), + }; + control.maps.push_back((applied, answer)); + let activated = restoring.activate(&mut control, deadlines).unwrap(); + let deadline = activated.deadline(); + let error = activated.start(&mut control).unwrap_err(); + assert_eq!(error.kind(), kind, "{name}: {error}"); + assert_eq!(error.message(), message, "{name}"); + let failed = std::error::Error::source(&error).unwrap().to_string(); + assert_eq!(failed, source, "{name}"); + if kind == ErrorKind::TimedOut { + assert!(Instant::now() >= deadline, "{name}"); + } + // The table was sent once, in the session in which the clone answered, and no start, nor + // another session, followed it. + assert_eq!(control.sessions, 1, "{name}"); + assert_eq!( + control.calls[2..], + [Call::Map { + session: 1, + table: data_table().to_vec(), + deadline, + }], + "{name}" + ); + assert_eq!(control.applied, data_table()[..applied], "{name}"); + // The launch ended, through the VMM's verified identity, and the sandbox records the + // failure, which is never the template's, so that none quarantines its generation. + assert_failed(&error, &acquired, ready, evidence, Teardown::Terminated); + assert!(!error.outcome().quarantines(), "{name}"); + assert_launch_ended(&fixture, &mut acquired, &vmm); + } + + // The guest's refusal of the table is the request's whenever it came, even past the deadline. + let mut acquired = fixture.acquire("late"); + let mut vmm = vmm_with("flush"); + let (restoring, _) = launch_request( + &fixture, + &mut acquired, + &request, + &mut vmm, + Hooks::LAUNCH.end, + ); + let mut control = FakeControl::default(); + control.maps.push_back((0, Answer::Fail(Failure::TABLE))); + let passed = RestoreDeadlines { + start: Duration::ZERO, + ..RestoreDeadlines::default() + }; + let activated = restoring.activate(&mut control, passed).unwrap(); + let error = activated.start(&mut control).unwrap_err(); + assert_eq!( + (error.kind(), error.message()), + (ErrorKind::Protocol, refused) + ); + assert_failed( + &error, + &acquired, + ready, + Evidence::InvalidMappings, + Teardown::Terminated, + ); + assert_launch_ended(&fixture, &mut acquired, &vmm); + + // The guest ended the clone's VM as its table failed, with the status of an export that does + // not mount: no start was sent, so no workload ran, and the status is evidence, which prevails + // over the loss of the session. + let mut acquired = fixture.acquire("status"); + let mut vmm = vmm_with("flush report:guest-exit:56"); + let (restoring, _) = launch_request( + &fixture, + &mut acquired, + &request, + &mut vmm, + Hooks::LAUNCH.end, + ); + let mut control = FakeControl::default(); + control.maps.push_back((0, Answer::Fail(Failure::LOST))); + let activated = restoring + .activate(&mut control, RestoreDeadlines::default()) + .unwrap(); + vmm.end_and_wait(); + let error = activated.start(&mut control).unwrap_err(); + assert_eq!(error.message(), lost); + assert_failed( + &error, + &acquired, + ready, + Evidence::InvalidMappings, + Teardown::Exited, + ); + assert_eq!(error.record().status, Some(56)); + assert_launch_ended(&fixture, &mut acquired, &vmm); +} diff --git a/nvx-rs/src/host/template/mod.rs b/nvx-rs/src/host/template/mod.rs index 4e05c8b01..cc3df7b37 100644 --- a/nvx-rs/src/host/template/mod.rs +++ b/nvx-rs/src/host/template/mod.rs @@ -33,19 +33,22 @@ //! leases have drained. //! - [`ClonePlan::new`] plans the clone of a leased generation, as a [`CloneRequest`] asks, under //! the lock that acquired the lease, before the clone's launch: it checks the clone's image -//! against its binding, its upper, in RAM or on the caller's scratch image, which it claims, and -//! its memory target against the template, and refuses a request that the template cannot -//! serve, such as an image of another length or block geometry than the template's placeholder, -//! before any VMM starts. -//! - [`ClonePlan::launch`] launches the clone, as the launch that its lease reserves: OpenVMM -//! restores the generation's snapshot in place with the clone's devices, and the clone's -//! activation record waits in its sandbox, as a [`Restoring`] describes. +//! against its binding, its upper, in RAM or on the caller's scratch image, which it claims, its +//! memory target against the template, and its export, if it has one, a plan of one directory, +//! which the template's dormant filesystem slot binds, and refuses a request that the template +//! cannot serve, such as an image of another length or block geometry than the template's +//! placeholder, or an export of several directories or of a file, before any VMM starts. +//! - [`ClonePlan::launch`] launches the clone, as the launch that its lease reserves, once the +//! objects of its export are still those that its plan found: OpenVMM restores the generation's +//! snapshot in place with the clone's devices and export, and the clone's activation record +//! waits in its sandbox, as a [`Restoring`] describes. //! - [`Restoring::activate`] runs the clone's restore sequence through the adapter's framing of //! the clone's control session, a [`CloneControl`]: it waits for OpenVMM's restore-ready event, //! then connects, sends the clone's activation record in the adapter's Activate, and checks the //! activation that the clone's Ready reports, by a deadline that starts with the launch, and //! resends the same record in a new session once the adapter lost one. [`Activated::start`] -//! then starts the clone's sandbox by a deadline that starts at its Ready, and +//! then sends the clone's mapping table, if it has an export, and starts the clone's sandbox +//! once the guest applied every mapping, by a deadline that starts at its Ready, and //! [`Started::commit`] commits the launch. //! - The plan, the launch, and the restore sequence fail with a [`CloneFailure`], whose //! [`Evidence`] attributes it to an [`Outcome`], at a [`Stage`], once the clone got as far as diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index cb4a2b035..ff91482cd 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -1035,19 +1035,26 @@ template's capture kept, as it activates the clone. Its first step is `host::activate` instead of `host::ready`: it sends the clone's activation record in an `Activate`, the first request of the session, and waits for `Ready`, whose build it checks as `host::ready` does, and which reports the -clone's activation. Then `host::start_sandbox` runs as after a cold boot. -`host::CloneOrder` runs both -steps as the framing of the restore sequence of `nvx::host::template`, which -checks the activation against the record, and which replaces a session that -the order takes for lost: one that a reset of the broker, the end of its -stream, or a failure of the transport ended while the host attached or -awaited `Ready`. A clone that was ready answers the same record, byte for -byte, with the same `Ready` in the new session, and one that took the reset -before it was ready has ended. OpenVMM's broker ends the stream both to end an -attachment and to refuse one, so the order takes such a refusal for a lost -session too, whose retries the sequence's limit of sessions bounds. Any other -failure is final: a refusal that the broker sends, a deadline that passed, -another build, and a message that breaks the framing. +clone's activation. The table and `Start` then follow as after a cold boot, in +two steps: `host::map_table` sends the table as `host::start_sandbox` does, +and ends the order in the same way at a part that the guest fails, and +`Client::start` sends `Start`. `host::CloneOrder` runs these steps as the +framing of the restore sequence of `nvx::host::template`, which checks the +activation against the record, sends the table only for a clone with an +export, and `Start` only once the guest applied every mapping, and which +replaces a session that the order takes for lost: one that a reset of the +broker, the end of its stream, or a failure of the transport ended while the +host attached or awaited `Ready`. A clone that was ready answers the same +record, byte for byte, with the same `Ready` in the new session, and one that +took the reset before it was ready has ended. OpenVMM's broker ends the stream +both to end an attachment and to refuse one, so the order takes such a refusal +for a lost session too, whose retries the sequence's limit of sessions bounds. +Any other failure is final: a refusal that the broker sends, a deadline that +passed, another build, and a message that breaks the framing. The guest's +`MapFailed` does not tell whether the request's mappings or a failure of the +guest's own failed the table, so the sequence attributes it, as any other +failure of the table, to what the host saw of it: a broken protocol, unless the +deadline passed or the session was lost. ## VMs diff --git a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs index fa2ad16b1..da38cd633 100644 --- a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs +++ b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs @@ -1959,10 +1959,11 @@ fn unanswered( } /// Returns the adapter's order of a clone, with the guest agent's build `build`, which the restore -/// sequence of a clone's start, and a reattach to it, take, the one place that makes it. A clone's -/// record announces no mapping table: the adapter maps nothing into a clone. +/// sequence of a clone's start, and a reattach to it, take, the one place that makes it. The +/// sequence sends through it the clone's mapping table, which the clone's record announces if the +/// clone has an export, and a reattach sends none. fn clone_order(build: &str) -> CloneOrder<'_> { - CloneOrder { build, table: &[] } + CloneOrder { build } } /// Ends a command whose clone, in the sandbox of `locked`, failed with `failure`, as NVX diff --git a/tests/runtime-adapter/src/clone.rs b/tests/runtime-adapter/src/clone.rs index 996c33ed4..ef85bb562 100644 --- a/tests/runtime-adapter/src/clone.rs +++ b/tests/runtime-adapter/src/clone.rs @@ -148,6 +148,7 @@ impl Request { binding, policy_revision: DISTRO_POLICY_REVISION, scratch, + export: None, memory, workload: WorkloadView { hostname: CLONE_HOSTNAME.as_bytes().to_vec(), diff --git a/tests/runtime-adapter/src/host.rs b/tests/runtime-adapter/src/host.rs index 339cae9b9..e33a42b9c 100644 --- a/tests/runtime-adapter/src/host.rs +++ b/tests/runtime-adapter/src/host.rs @@ -30,13 +30,14 @@ //! with the inputs that the template's capture kept, as it activates the clone. Its first step is //! [`activate`], which sends the clone's activation record in an `Activate`, the first request of //! the session, and waits for the guest agent's `Ready`, whose build it checks as [`ready`] does, -//! and which reports the clone's activation, for the host to check against the record. Then -//! [`start_sandbox`] runs as after a cold boot. [`CloneOrder`] runs both steps as the adapter's -//! framing of `nvx::host::template`'s restore sequence, which checks the activation, and which -//! replaces a session that the order lost, as [`CloneOrder`] tells, while the clone was not ready -//! or its `Ready` had not arrived: the clone answers the same record, byte for byte, with the same -//! `Ready` in a new session, and has ended once the reset of a session that it took before it was -//! ready failed its activation. +//! and which reports the clone's activation, for the host to check against the record. Then the +//! restore sequence sends the clone's mapping table, which [`map_table`] sends as [`start_sandbox`] +//! does after a cold boot, and `Start`. [`CloneOrder`] runs these steps as the adapter's framing of +//! `nvx::host::template`'s restore sequence, which checks the activation, orders the steps, and +//! which replaces a session that the order lost, as [`CloneOrder`] tells, while the clone was not +//! ready or its `Ready` had not arrived: the clone answers the same record, byte for byte, with the +//! same `Ready` in a new session, and has ended once the reset of a session that it took before it +//! was ready failed its activation. mod executions; mod map_reply; @@ -455,9 +456,8 @@ pub fn ready( } /// Runs the rest of the host's order over `client`, once the guest agent is [`ready`]: sends the -/// mapping table `table`, which the root configuration announced, in the parts that -/// [`encode_maps`] splits it into, each once the guest applied the one before, then `Start`, and -/// waits until `deadline` for `Started`. A table of no mapping sends no part. +/// mapping table `table`, which the root configuration announced, with [`map_table`], then +/// `Start`, and waits until `deadline` for `Started`. A table of no mapping sends no part. /// /// Fails with [`OrderError::Table`], before it sends anything, for a table that does not encode; /// with [`OrderError::Mapping`] once the guest fails a part, after which it sends no more of the @@ -467,6 +467,23 @@ pub fn start_sandbox( client: &mut Client, table: &[HostMap], deadline: Instant, +) -> Result<(), OrderError> { + map_table(client, table, deadline)?; + client.start(deadline).map_err(OrderError::Client) +} + +/// Sends the mapping table `table` over `client`, once the guest agent is ready, in the parts that +/// [`encode_maps`] splits it into, each once the guest applied the one before, until `deadline`. +/// A table of no mapping sends no part. +/// +/// Fails as [`start_sandbox`] does before its `Start`: with [`OrderError::Table`], before it sends +/// anything, for a table that does not encode; with [`OrderError::Mapping`] once the guest fails a +/// part, after which it sends no more of the table, but asks the guest to stop; and with +/// [`OrderError::Client`] once the client fails. +pub fn map_table( + client: &mut Client, + table: &[HostMap], + deadline: Instant, ) -> Result<(), OrderError> { let parts = encode_maps(table).map_err(OrderError::Table)?; for part in &parts { @@ -480,7 +497,7 @@ pub fn start_sandbox( } } } - client.start(deadline).map_err(OrderError::Client) + Ok(()) } /// Runs the first step of [a clone's order](self#a-clones-order) over `client`, in place of @@ -514,8 +531,9 @@ pub fn activate( /// [A clone's order](self#a-clones-order), as the adapter's framing of the clone's control /// session, through which `nvx::host::template`'s restore sequence activates the clone and starts /// its sandbox: it attaches a [`Client`] over each connection to the clone's control endpoint, -/// runs [`activate`] with the guest agent's build that the host ships, then [`start_sandbox`] with -/// the host's mapping table, which the clone's record announces. +/// runs [`activate`] with the guest agent's build that the host ships, then [`map_table`] with the +/// mapping table that the restore sequence gives it, which the clone's record announces, and +/// `Start`. /// /// It takes a session for lost once a reset of the broker, the end of the broker's stream, or a /// failure of the transport ended it, while the client attached or awaited the clone's `Ready`: @@ -525,12 +543,16 @@ pub fn activate( /// restore sequence's limit of sessions bounds. Any other failure is final: a refusal that the /// broker sends, a deadline that passed, a guest agent of another build, and a message that breaks /// the framing. +/// +/// It takes no failure for the guest's refusal of the request's mapping table, which +/// [`CloneControl::refused`] tells: the guest's `MapFailed` does not tell whether the request's +/// mappings or a failure of the guest's own failed the table, so the restore sequence attributes +/// it, as any other failure of the table, to what the host saw of it, a broken protocol, unless +/// the deadline passed or the session was lost. #[derive(Clone, Copy, Debug)] pub struct CloneOrder<'a> { /// The guest agent's build that the host ships. pub build: &'a str, - /// The host's mapping table, which the clone's activation record announces. - pub table: &'a [HostMap], } impl CloneControl for CloneOrder<'_> { @@ -555,8 +577,17 @@ impl CloneControl for CloneOrder<'_> { activate(session, record, self.build, deadline).map(|ready| ready.activation()) } + fn map( + &mut self, + session: &mut Client, + table: &[HostMap], + deadline: Instant, + ) -> Result<(), OrderError> { + map_table(session, table, deadline) + } + fn start(&mut self, session: &mut Client, deadline: Instant) -> Result<(), OrderError> { - start_sandbox(session, self.table, deadline) + session.start(deadline).map_err(OrderError::Client) } fn lost(&self, error: &OrderError) -> bool { diff --git a/tests/runtime-adapter/src/host/order_error.rs b/tests/runtime-adapter/src/host/order_error.rs index 60a5e6b6c..1ee1a5e5c 100644 --- a/tests/runtime-adapter/src/host/order_error.rs +++ b/tests/runtime-adapter/src/host/order_error.rs @@ -1,5 +1,5 @@ //! How the [host's order](super#the-hosts-order) of a VM with a layered root fails, which -//! [`ready`](super::ready), [`activate`](super::activate), and +//! [`ready`](super::ready), [`activate`](super::activate), [`map_table`](super::map_table), and //! [`start_sandbox`](super::start_sandbox) return: an [`OrderError`]. use std::fmt; diff --git a/tests/runtime-adapter/tests/host_session.rs b/tests/runtime-adapter/tests/host_session.rs index d39b7b043..83f35dd25 100644 --- a/tests/runtime-adapter/tests/host_session.rs +++ b/tests/runtime-adapter/tests/host_session.rs @@ -1873,13 +1873,9 @@ fn clone_ready() -> Message { Message::Ready(ready().with_activation(clone_activation()).encode()) } -/// Returns the order of a clone of the guest agent's build that the host program ships, with no -/// mapping. +/// Returns the order of a clone of the guest agent's build that the host program ships. fn clone_order() -> CloneOrder<'static> { - CloneOrder { - build: GUEST_BUILD, - table: &[], - } + CloneOrder { build: GUEST_BUILD } } /// Runs the first step of a clone's order with `client` in a thread: sends [`activation_record`] in an @@ -1916,19 +1912,20 @@ fn a_clone_s_order_sends_its_record_first_then_starts_its_sandbox_as_after_a_col let (client, activated) = joined(activation); assert_eq!(activated.unwrap(), Some(clone_activation())); - // Then the table, in its parts, each once the guest applied the one before, and `Start`. + // Then the table that the restore sequence gives the order, in its parts, each once the guest + // applied the one before, and `Start`, which the sequence asks for once the table succeeded. let table = long_table(); let parts = encode_maps(&table).unwrap(); - let starting = spawn(client, move |client| { - let mut order = CloneOrder { - build: GUEST_BUILD, - table: &table, - }; - order.start(client, deadline()) + let mapping = spawn(client, move |client| { + clone_order().map(client, &table, deadline()) }); for part in parts { broker.expect(Message::Maps(part), &Message::Mapped); } + let (client, mapped) = joined(mapping); + mapped.unwrap(); + broker.assert_quiet(); + let starting = spawn(client, |client| clone_order().start(client, deadline())); broker.expect(Message::Start, &Message::Started); let (client, started) = joined(starting); started.unwrap(); @@ -1951,6 +1948,51 @@ fn a_clone_s_order_sends_its_record_first_then_starts_its_sandbox_as_after_a_col assert_eq!(readiness.activation(), Some(clone_activation())); } +#[test] +fn a_clone_s_order_ends_its_table_at_a_mapping_that_the_guest_fails_and_stops_the_guest() { + // As after a cold boot, the guest fails a part of the table, once it applied the mappings + // before it: the order sends no more of the table, and asks the guest to stop, and the restore + // sequence, which the failure ends, sends no `Start`. + let table = long_table(); + let parts = encode_maps(&table).unwrap(); + assert!(parts.len() > 2); + let (mut broker, client) = attached(FIRST, usize::MAX); + // The table follows the clone's `Ready`, which answers its record. + let activation = activating(client); + broker.expect(Message::Activate(activation_record()), &clone_ready()); + let (client, activated) = joined(activation); + assert_eq!(activated.unwrap(), Some(clone_activation())); + let mapping = spawn(client, move |client| { + clone_order().map(client, &table, deadline()) + }); + broker.expect(Message::Maps(parts[0].clone()), &Message::Mapped); + let applied = decode_maps(&parts[1]).unwrap().first; + let failure = MapFailure::new(applied, "the target is not a directory"); + broker.expect( + Message::Maps(parts[1].clone()), + &Message::MapFailed(failure.encode()), + ); + assert_eq!(broker.messages(1), [Message::Stop]); + broker.send_data(&encode(&[Message::Stopped]), OUTER_MAX_PAYLOAD); + let (client, mapped) = joined(mapping); + let error = mapped.unwrap_err(); + let OrderError::Mapping { + failure: found, + stop, + } = &error + else { + panic!("{error:?}"); + }; + assert_eq!(*found, failure); + assert!(matches!(stop, Ok(None)), "{stop:?}"); + // The guest's refusal of the table keeps the session, but does not tell whether the request's + // mappings failed it, so the order takes it for no refusal of the request's. + assert!(!clone_order().lost(&error)); + assert!(!clone_order().refused(&error)); + drop(client); + assert!(broker.read().is_none(), "the host sent more"); +} + #[test] fn a_clone_s_session_that_ends_after_its_bootstrap_is_lost_and_the_next_takes_the_same_record() { let next = Identity { epoch: 2, ..FIRST }; diff --git a/tests/runtime-adapter/tests/vm_clone.rs b/tests/runtime-adapter/tests/vm_clone.rs index 22fa07a7e..987df2adb 100644 --- a/tests/runtime-adapter/tests/vm_clone.rs +++ b/tests/runtime-adapter/tests/vm_clone.rs @@ -104,9 +104,9 @@ use nvx::host::template::{ Generation, HostTime, KEY_NAME, Lease, MAX_DOWNTIME_NS, MEMORY_BLOCK_SIZE, Outcome, Progress, RestoreDeadlines, SNAPSHOT_NAME, Stage, Store, Teardown, }; -use nvx::protocol::CAPABILITY_LEN; use nvx::protocol::root::ImageFormat; use nvx::protocol::status; +use nvx::protocol::{CAPABILITY_LEN, HostMap}; use nvx::uuid::Uuid; use runtime_adapter::GUEST_BUILD; use runtime_adapter::capture::DEFAULT_SCRATCH_LENGTH; @@ -1041,6 +1041,15 @@ impl CloneControl for Early { }) } + fn map( + &mut self, + _session: &mut Client, + _table: &[HostMap], + _deadline: Instant, + ) -> io::Result<()> { + unreachable!("a clone that is never ready gets no mapping table") + } + fn start(&mut self, _session: &mut Client, _deadline: Instant) -> io::Result<()> { Err(io::Error::other("the clone is never ready")) } From 45c6463405251939df076d068df9757a58217391 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sun, 25 Oct 2026 17:24:29 -0700 Subject: [PATCH 2/5] nvx-rs: let an empty mapping source name the export's root A clone whose export is one directory gets that directory alone through the template's dormant slot, so its mapping table maps the directory itself with an empty source: the export's root has no child index, as an aggregate's children do. The MAPS protocol and the guest now accept an empty source as the export's root: - nvx::protocol::maps accepts it in the encoder and both decoders. - nvx::guest::mappings::Mapping::new takes it for the source root itself, which the mapper opens as "." beneath the root with the same RESOLVE_BENEATH, RESOLVE_NO_SYMLINKS, RESOLVE_NO_MAGICLINKS, and RESOLVE_NO_XDEV flags, so the mapping is a copy of the export's own mount, without its submounts. Everything else stays refused: an absolute source ("/" included), an empty, "." or ".." component inside a non-empty source, a NUL byte, a path of more than 4,095 bytes, and every target that was refused before. NVX's managed agent, whose export is always an aggregate whose root lists its children, keeps refusing an empty source, with one check before it maps anything, so NVX's own sandboxes accept no more than before. The host and the guest deploy together, so no capability discovery announces the change. Version 1's conformance vector of an empty source moves from the rejection vectors (INVALID_MAPS|EMPTY_SOURCE) to the mapping vectors (MAPS|ROOT_SOURCE), with the same bytes. That is an exception to the rule that a vector changes only with a protocol version, which the vectors' README and design section 4.4.4 now record: only the clone of a template whose export is a directory sends an empty source, to the template's own guest, which deploys with the host, so the C agent of earlier releases, which refuses it, never receives one. The tests cover an empty source's round trips through the encoder and both decoders, its vector, the targets that it still needs, the mapping of the source root beneath it, with a fake kernel and as root with the real one, the refusals of a symbolic link and of a mount crossing beneath it, the managed agent's refusal, and a clone's table, with its root bind, through the MAPS payloads of the fake transport and of the test adapter's order. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 34e6ffe3-3ebf-42db-bfca-e0ecfade6873 --- .../4.4-host-guest-control-interface.md | 16 +++- nvx-rs/README.md | 27 ++++--- nvx-rs/src/guest/managed_agent/mappings.rs | 52 +++++++++++-- nvx-rs/src/guest/mappings.rs | 49 +++++++----- nvx-rs/src/guest/mappings/privileged.rs | 59 ++++++++++++++- nvx-rs/src/guest/mappings/tests.rs | 74 ++++++++++++++++++- .../src/host/template/clone/tests/restore.rs | 34 +++++++-- nvx-rs/src/protocol/maps.rs | 34 +++++---- nvx-rs/src/protocol/tests.rs | 24 +++++- nvx-rs/tests/vectors/README.md | 6 ++ nvx-rs/tests/vectors/nvxc-v1.txt | 9 ++- tests/runtime-adapter/README.md | 7 +- tests/runtime-adapter/tests/host_session.rs | 39 ++++++++++ 13 files changed, 359 insertions(+), 71 deletions(-) diff --git a/doc/design/4.4-host-guest-control-interface.md b/doc/design/4.4-host-guest-control-interface.md index 8c5b5390f..7c6c7a7e7 100644 --- a/doc/design/4.4-host-guest-control-interface.md +++ b/doc/design/4.4-host-guest-control-interface.md @@ -27,6 +27,13 @@ Table 4.2 lists the operations of the control protocol. | `CANCEL` | Kills the running execution that the request names | | Shutdown | Shuts the VM down gracefully between executions | +A mapping request carries a contiguous range of the host mapping table's +entries, each with its access, a source relative to the guest's mount of the +host's export, and an absolute target in the guest's root. An empty source +names the export's root itself, as the table of a directory export maps the +whole directory. NVX's managed agent, whose export is an aggregate of host +directories and files, whose root lists them, refuses an empty source. + An execution request carries a 32-bit timeout and length-prefixed arguments, and optionally an absolute working directory of at most 4,096 bytes and up to 256 `KEY=VALUE` environment entries of at most 4,096 bytes each, with @@ -126,5 +133,10 @@ feature payloads, and records of the session that carries them. [`openvmm/`](../../nvx-rs/tests/vectors/openvmm) holds the VMM's own vectors and cases of the session's records, unchanged. A vector changes only with a protocol version, which gets vector files of its own, and the bytes of an -earlier version that it refuses become its rejection vectors. The vectors' -[README](../../nvx-rs/tests/vectors/README.md) describes their line format. +earlier version that it refuses become its rejection vectors. Version 1 has +one exception: the bytes of a mapping with an empty source were a rejection +vector until an empty source came to name the export's root. Only the clone +of a template whose export is a directory sends one, to the template's own +guest, which deploys with the host, so no earlier guest receives one. The +vectors' [README](../../nvx-rs/tests/vectors/README.md) describes their line +format. diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 3faa33ba9..f2e66b261 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -739,14 +739,15 @@ nothing else. `Activated::start` then sends the clone's mapping table, if it has an export, through `CloneControl::map`, in order, from paths in the exported directory, -which name no child's index, and starts the sandbox once the guest applied every -mapping, both by a Ready-to-Start deadline from the check of Ready, -`READY_TO_START_TIMEOUT`, 30 seconds by default, never sending either again, and -`Started::commit` commits the launch, which writes its runtime record. A -failure, a partial or failed mapping table included, ends the launch as -`Starting::abandon` does; a crash before the commit leaves the launch's journal, -whose recovery ends the VMM, while the lease lasts until the release, and a -crash after it leaves a VM that reattaches, and keeps its lease while it runs. +which name no child's index, with an empty source for the directory itself, and +starts the sandbox once the guest applied every mapping, both by a +Ready-to-Start deadline from the check of Ready, `READY_TO_START_TIMEOUT`, 30 +seconds by default, never sending either again, and `Started::commit` commits +the launch, which writes its runtime record. A failure, a partial or failed +mapping table included, ends the launch as `Starting::abandon` does; a crash +before the commit leaves the launch's journal, whose recovery ends the VMM, +while the lease lasts until the release, and a crash after it leaves a VM that +reattaches, and keeps its lease while it runs. A failure of a clone is a `CloneFailure`, whose `Evidence` attributes it to an `Outcome`, at a `Stage`, once the clone got as far as its `Progress`: the @@ -959,8 +960,10 @@ descriptor of a `TargetRoot`, the root of a mount of a whole file system, which overlay or a guest's `/`, from a `SourceRoot`, such as the guest's mount of the host's export. -A `Mapping` names a relative source, an absolute target, its `Access`, and -optionally the `Kind` that its source must have. +A `Mapping` names a relative source, or an empty one for the source root itself, +such as a directory export's root, which the mapper opens as `.` beneath it with +the same flags, an absolute target, its `Access`, and optionally the `Kind` that +its source must have. The mapper opens each component with `openat2`, through no symbolic link or magic link, creates what a target lacks only on the root's own mounts, which @@ -982,7 +985,9 @@ failure. Linux 6.8 or later. NVX's managed agent applies its host mapping table through it, beneath `/` in direct mode and beneath the container's assembled root in -sandbox mode, and a layered `Root` its live shares and mappings. +sandbox mode, and refuses an empty source, since its export is an aggregate, +whose root lists its children; a layered `Root` applies its live shares and +mappings through it. ### `nvx::guest::nvxs` diff --git a/nvx-rs/src/guest/managed_agent/mappings.rs b/nvx-rs/src/guest/managed_agent/mappings.rs index ec588e164..ceeb6ac29 100644 --- a/nvx-rs/src/guest/managed_agent/mappings.rs +++ b/nvx-rs/src/guest/managed_agent/mappings.rs @@ -256,12 +256,17 @@ pub(crate) fn teardown(system: &mut impl System, mappings: &mut HostMappings) -> } /// Returns the library's mapping of one decoded entry, which the decoder's path rules let -/// through unchanged. +/// through unchanged, and refuses an entry of the export's root, which an empty source names: the +/// decoder and the library take it for the root of a directory export, but the agent's export is +/// an aggregate, whose root lists its children, which the agent never maps. /// /// The paths are the decoder's buffers, which it bounds: it refuses a payload of more than /// `MAX_HOST_MAPPINGS` entries before it allocates them, and a source or target of more than /// `MAX_MAP_PATH_BYTES` bytes. fn mapping_of(entry: GuestHostMap) -> Result { + if entry.source.is_empty() { + return Err(InvalidMapping::Source); + } let path = |bytes: Vec| PathBuf::from(OsString::from_vec(bytes)); let access = if entry.read_only { Access::ReadOnly @@ -565,7 +570,6 @@ mod tests { &[good, (b"0/src", target.as_os_str().as_bytes(), 2)], None, ), - payload(0, &[good, (b"", target.as_os_str().as_bytes(), 0)], None), [payload(0, &[good], None), vec![0]].concat(), payload(0, &[good], Some(2)), payload(0, &[], Some(0)), @@ -582,6 +586,41 @@ mod tests { assert!(!target.exists()); } + #[test] + fn the_root_of_the_export_is_refused_though_it_decodes() { + // An empty source names the export's root, which a directory export maps, but the + // agent's export is an aggregate, whose root lists its children: the agent refuses the + // table that holds one, as it refuses a malformed one, and mounts none of it. + let (_temporary, mut system, mut mappings, guest) = setup(2); + let (first, root) = (guest.join("first"), guest.join("root")); + let request = payload( + 0, + &[ + (b"0/src", first.as_os_str().as_bytes(), 0), + (b"", root.as_os_str().as_bytes(), protocol::MAP_READ_ONLY), + ], + None, + ); + let decoded = protocol::decode_guest_maps_payload(&request).unwrap(); + assert!(decoded.maps[1].source.is_empty()); + assert!(matches!( + apply_request(&mut system, &mut mappings, &request), + Err(MappingError::Invalid) + )); + assert!(system.kernel.calls().is_empty()); + assert_eq!((mappings.applied, mappings.failed), (0, false)); + assert!(!first.exists() && !root.exists()); + // So does a sandbox agent, whose export is an aggregate too. + let (_temporary, mut system, mut mappings, rootfs) = sandbox(1); + let request = payload(0, &[(b"", b"/workspace", 0)], None); + assert!(matches!( + apply_request(&mut system, &mut mappings, &request), + Err(MappingError::Invalid) + )); + assert!(system.kernel.calls().is_empty()); + assert!(!rootfs.join("workspace").exists()); + } + #[test] fn without_an_announcement_tables_are_refused() { let (_temporary, mut system, mut mappings, guest) = setup(0); @@ -664,7 +703,7 @@ mod tests { } #[test] - fn every_entry_that_the_decoder_accepts_becomes_a_library_mapping() { + fn every_entry_that_the_decoder_accepts_but_the_export_s_root_becomes_a_library_mapping() { assert_eq!( crate::guest::mappings::MAX_PATH_BYTES, protocol::MAX_MAP_PATH_BYTES @@ -680,12 +719,15 @@ mod tests { protocol::MAP_READ_ONLY, ), (b"0/\xff", b"/\xfe", 0), + (b"", b"/root", 0), ], None, )) .unwrap(); - let converted = request - .maps + let mut entries = request.maps; + let root = entries.pop().unwrap(); + assert_eq!(mapping_of(root), Err(InvalidMapping::Source)); + let converted = entries .into_iter() .map(mapping_of) .collect::, _>>() diff --git a/nvx-rs/src/guest/mappings.rs b/nvx-rs/src/guest/mappings.rs index b94f542fb..7dfe1bb32 100644 --- a/nvx-rs/src/guest/mappings.rs +++ b/nvx-rs/src/guest/mappings.rs @@ -16,11 +16,13 @@ //! `RESOLVE_NO_MAGICLINKS`: a symbolic link or a magic link at any component, the last //! included, is refused, and no path leads above the root. It opens a source beneath its root //! the same way, in one call, and with `RESOLVE_NO_XDEV` too, so that a source is on its root's -//! mount. A target root is the root of a mount, and that mount, like each that the caller adds -//! with [`TargetRoot::add_mount`], shows its whole file system, which `statmount` tells, rather -//! than being a bind of a directory below that file system's root: no rename, through it or -//! another mount of its file system, can move a file out of it, so nothing that the walk opened -//! on one of the root's own mounts leaves the root, whatever is renamed meanwhile. +//! mount. An empty source names the source root itself, such as the root of a directory export, +//! which it opens as `.` beneath the root, with the same flags. A target root is the root of a +//! mount, and that mount, like each that the caller adds with [`TargetRoot::add_mount`], shows its +//! whole file system, which `statmount` tells, rather than being a bind of a directory below that +//! file system's root: no rename, through it or another mount of its file system, can move a file +//! out of it, so nothing that the walk opened on one of the root's own mounts leaves the root, +//! whatever is renamed meanwhile. //! //! # Creation //! @@ -97,9 +99,10 @@ //! mapper that is dropped leaves its mappings mounted. //! //! NVX's managed agent applies its host mapping table through a mapper beneath its own root, `/`, -//! from its mount of the host's export. A layered [`Root`](super::root::Root) mounts its live -//! shares, and the mappings that its caller applies, through one, which its teardown unmounts -//! first. +//! from its mount of the host's export, an aggregate, whose root, which lists the aggregate's +//! children, it never maps: it refuses an empty source. A layered [`Root`](super::root::Root) +//! mounts its live shares, and the mappings that its caller applies, through one, which its +//! teardown unmounts first. //! //! ```no_run //! use std::path::Path; @@ -227,8 +230,9 @@ impl fmt::Display for Kind { #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum InvalidMapping { - /// The source is not a relative path of at most [`MAX_PATH_BYTES`] bytes without NUL bytes, - /// whose components are neither empty, `.`, nor `..`. + /// The source is neither empty, for the source root itself, nor a relative path of at most + /// [`MAX_PATH_BYTES`] bytes without NUL bytes, whose components are neither empty, `.`, nor + /// `..`. Source, /// The target is not an absolute path of at most [`MAX_PATH_BYTES`] bytes without NUL bytes, /// whose components after the leading `/` are neither empty, `.`, nor `..`. @@ -238,7 +242,7 @@ pub enum InvalidMapping { impl fmt::Display for InvalidMapping { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { let (path, form) = match self { - Self::Source => ("source", "a relative"), + Self::Source => ("source", "empty, for its root, or a relative"), Self::Target => ("target", "an absolute"), }; write!( @@ -269,19 +273,20 @@ pub struct Mapping { impl Mapping { /// Describes the mapping of `source`, a path relative to the source root, at `target`, an /// absolute path as seen from the target root, with `access`: for example `0/workspace` at - /// `/workspace`. The target takes the kind of the source, whatever it is, unless - /// [`with_kind`](Self::with_kind) asks for one. + /// `/workspace`. An empty `source` names the source root itself, such as the root of a + /// directory export, whose whole directory the mapping then maps. The target takes the kind of + /// the source, whatever it is, unless [`with_kind`](Self::with_kind) asks for one. /// /// Each path holds at most [`MAX_PATH_BYTES`] bytes, no NUL byte, and no empty, `.`, or `..` - /// component, so that it names a path below its root; [`InvalidMapping`] tells which one does - /// not. Other bytes need not be UTF-8. + /// component, so that it names a path below its root, or, for an empty source, the root + /// itself; [`InvalidMapping`] tells which one does not. Other bytes need not be UTF-8. pub fn new( source: impl Into, target: impl Into, access: Access, ) -> Result { let source = source.into(); - if below_its_root(&source, false).is_none() { + if !source.as_os_str().is_empty() && below_its_root(&source, false).is_none() { return Err(InvalidMapping::Source); } let target = below_its_root(&target.into(), true) @@ -301,7 +306,7 @@ impl Mapping { self } - /// Returns the source, relative to the source root. + /// Returns the source, relative to the source root: empty for the source root itself. pub fn source(&self) -> &Path { &self.source } @@ -1118,12 +1123,20 @@ fn attach( } } -/// Opens `path` beneath `source`, and returns it and its kind, which must be a mapping's. +/// Opens `path` beneath `source`, or the source root itself for an empty `path`, and returns it +/// and its kind, which must be a mapping's. fn open_source( kernel: &mut dyn Kernel, source: &SourceRoot, path: &Path, ) -> Result<(FileDescriptor, Kind), MappingError> { + // `openat2` resolves no empty path, so the root itself is `.` beneath it, which the same + // resolution confines to the root's own mount, as it does every other source. + let path = if path.as_os_str().is_empty() { + Path::new(".") + } else { + path + }; let opened = sys::path_c_string(path).and_then(|path| { let descriptor = kernel.open_beneath( &source.directory, diff --git a/nvx-rs/src/guest/mappings/privileged.rs b/nvx-rs/src/guest/mappings/privileged.rs index 92851c3d3..3fe560bbd 100644 --- a/nvx-rs/src/guest/mappings/privileged.rs +++ b/nvx-rs/src/guest/mappings/privileged.rs @@ -4,8 +4,9 @@ //! `cargo test --all-features -- --ignored guest::mappings::privileged`, as root. //! //! They cover what the fake kernel cannot: symbolic links and magic links at every component, a -//! rename race during the walk, kinds, a target inside another mapping, reserved targets, a -//! mapping that covers the root of one of the root's own mounts until the teardown, what a mapper +//! rename race during the walk, kinds, a target inside another mapping, reserved targets, the +//! source root itself, which an empty source maps as a copy of its own mount alone, a mapping +//! that covers the root of one of the root's own mounts until the teardown, what a mapper //! creates, which a workload that is not root enters whatever the umask or a default ACL, a //! read-only mapping that is never visible writable, even briefly, a teardown's unmounts, from a //! thread with a descriptor table of its own too, and the roots of binds of directories below @@ -380,6 +381,60 @@ fn kinds_nested_targets_and_reserved_targets_are_checked() { assert!(mounts_below(&root).is_empty()); } +#[test] +#[ignore = "needs root and Linux 6.8 or later"] +fn an_empty_source_maps_a_copy_of_the_source_root_s_own_mount() { + let Some(scene) = Scene::new() else { + return; + }; + let (root, export) = (scene.path("root"), scene.path("export")); + // A file system mounted below the source root, and a link in it. + let inner = export.join("dir/inner"); + sys::mount( + Some(c"tmpfs"), + &sys::path_c_string(&inner).unwrap(), + Some(c"tmpfs"), + MountFlags::empty(), + Some(c"mode=0755"), + ) + .unwrap(); + fs::write(inner.join("below"), b"below").unwrap(); + std::os::unix::fs::symlink("dir", export.join("link")).unwrap(); + let mut mapper = scene.mapper(Reserved::default()); + let whole = Mapping::new("", "/whole", Access::ReadOnly).unwrap(); + mapper.apply(&scene.source(), &[whole]).unwrap(); + // The target is the root of a copy of the source root's own mount, read-only, `nosuid`, and + // `nodev`, which shows the whole export, but not the file system mounted below it. + let mapped = root.join("whole"); + let (mount, mount_root) = mount_of(&mapped); + assert!(mount_root); + assert_ne!(mount, mount_of(&export).0); + assert_eq!(fs::read(mapped.join("file.txt")).unwrap(), b"file"); + assert_eq!(fs::read(mapped.join("dir/marker")).unwrap(), b"host"); + assert_eq!(fs::read_dir(mapped.join("dir/inner")).unwrap().count(), 0); + let flags = statvfs(&mapped).unwrap().flags(); + assert!(flags.contains(FsFlags::ST_RDONLY | FsFlags::ST_NOSUID | FsFlags::ST_NODEV)); + let written = fs::write(mapped.join("new"), b"guest").unwrap_err(); + assert_eq!(written.raw_os_error(), Some(Errno::EROFS as i32)); + assert_eq!(mounts_below(&root), [mapped]); + // Every other source still opens through no link, and on the source root's own mount. + let refused = |error: Errno| MappingError::System { + step: Step::Source, + error: error as i32, + }; + assert_eq!( + scene.refusal(Reserved::default(), "link", "/link"), + refused(Errno::ELOOP) + ); + assert_eq!( + scene.refusal(Reserved::default(), "dir/inner", "/inner"), + refused(Errno::EXDEV) + ); + mapper.teardown().unwrap(); + assert!(mounts_below(&root).is_empty()); + assert!(!export.join("new").exists()); +} + #[test] #[ignore = "needs root and Linux 6.8 or later"] fn a_mapping_covers_the_root_of_a_mount_only_if_the_root_owns_it() { diff --git a/nvx-rs/src/guest/mappings/tests.rs b/nvx-rs/src/guest/mappings/tests.rs index 0b05ab275..df2b5dfc0 100644 --- a/nvx-rs/src/guest/mappings/tests.rs +++ b/nvx-rs/src/guest/mappings/tests.rs @@ -1133,17 +1133,22 @@ fn mappings_name_paths_below_their_roots() { Access::ReadOnly, ) .unwrap(); + // An empty source names the source root itself. + let root = Mapping::new("", "/target", Access::ReadOnly).unwrap(); + assert_eq!(root.source(), Path::new("")); let too_long = format!("{longest}a"); for source in [ - "", + "/", "/0", ".", "..", + "./", "a/../b", "a/./b", "a//b", "a/", "a\0b", + "\0", too_long.as_str(), ] { assert_eq!( @@ -1196,6 +1201,73 @@ fn mappings_name_paths_below_their_roots() { NUL bytes or empty, `.`, or `..` components" ) ); + assert_eq!( + InvalidMapping::Source.to_string(), + format!( + "a mapping source must be empty, for its root, or a relative path of at most \ + {MAX_PATH_BYTES} bytes without NUL bytes or empty, `.`, or `..` components" + ) + ); +} + +#[test] +fn an_empty_source_maps_the_source_root_itself_from_beneath_it() { + let fixture = Fixture::new(); + fs::write(fixture.path("root/plain"), b"").unwrap(); + // The source root itself, which the mapper opens as `.` beneath it, is copied alone and + // restricted before it is attached, as any directory below it is. + let mut mapper = fixture.mapper(Reserved::default()); + let whole = mapping("", "/whole", Access::ReadOnly); + fixture.apply(&mut mapper, &[whole]).unwrap(); + assert_eq!( + fixture.kernel.calls(), + attach_calls( + &fixture.path("export"), + &fixture.path("root/whole"), + Access::ReadOnly + ) + ); + assert!(fixture.path("root/whole").is_dir()); + mapper.teardown_with(&mut fixture.kernel()).unwrap(); + // It is a directory, which goes only onto a directory, and only where a mapping of a + // directory may go. + let calls = fixture.kernel.calls(); + let refusal = |mapping: Mapping| fixture.refusal(Reserved::default(), mapping); + let read_only = Access::ReadOnly; + assert_eq!( + refusal(mapping("", "/plain", read_only)), + MappingError::TargetKind + ); + let file = mapping("", "/file", read_only).with_kind(Kind::File); + assert_eq!(refusal(file), MappingError::SourceKind); + assert_eq!( + refusal(mapping("", "/run/nvx", read_only)), + MappingError::Reserved + ); + // Every other source is still opened through no link, and on the root's mount, once a mount + // below the root shows up, beneath which the root itself still opens. + fixture.kernel.add_mount(fixture.path("export/dir")); + assert_eq!( + refusal(mapping("link", "/link", read_only)), + failed(Step::Source, Errno::ELOOP) + ); + assert_eq!( + refusal(mapping("dir", "/crossed", read_only)), + failed(Step::Source, Errno::EXDEV) + ); + assert_eq!(fixture.kernel.calls(), calls); + let mut mapper = fixture.mapper(Reserved::default()); + let again = mapping("", "/again", Access::ReadWrite); + fixture.apply(&mut mapper, &[again]).unwrap(); + let attached: Vec = calls + .into_iter() + .chain(attach_calls( + &fixture.path("export"), + &fixture.path("root/again"), + Access::ReadWrite, + )) + .collect(); + assert_eq!(fixture.kernel.calls(), attached); } #[test] diff --git a/nvx-rs/src/host/template/clone/tests/restore.rs b/nvx-rs/src/host/template/clone/tests/restore.rs index b23972635..c33c19030 100644 --- a/nvx-rs/src/host/template/clone/tests/restore.rs +++ b/nvx-rs/src/host/template/clone/tests/restore.rs @@ -25,6 +25,9 @@ use crate::host::store::{CLONE_ACTIVATION_NAME, Recovery}; use crate::host::supervisor::{Starting, StopOutcome}; use crate::host::template::clone::launch::Hooks; use crate::host::template::clone::records::{End, read_failure}; +use crate::protocol::{ + APP_HEADER_LEN, OUTER_MAX_PAYLOAD, decode_maps_payload, encode_maps_payloads, +}; /// How long a VMM whose plan delays its event waits before it sends it. const DELAY: Duration = Duration::from_millis(300); @@ -53,7 +56,8 @@ enum Call { record: Vec, deadline: Instant, }, - /// A mapping table in a session, by the deadline. + /// A mapping table in a session, by the deadline, as the guest decodes the `MAPS` payloads in + /// which the framing encodes it. Map { session: usize, table: Vec, @@ -129,9 +133,10 @@ struct FakeSession { /// The adapter's framing of the tests: it attaches a session to each connection that the sequence /// makes, numbered from 1, answers each Activate as the next of its `activates` says, each mapping -/// table as the next of its `maps` says, and each start as the next of its `starts` says, as -/// [`Answer::Done`] does once they run out, and records each call. Its `on_activate` runs at each -/// Activate, with the session's number, before the answer. +/// table, which it encodes in `MAPS` payloads that its guest decodes, as the next of its `maps` +/// says, and each start as the next of its `starts` says, as [`Answer::Done`] does once they run +/// out, and records each call. Its `on_activate` runs at each Activate, with the session's number, +/// before the answer. #[derive(Default)] struct FakeControl<'a> { /// The calls, in order. @@ -280,14 +285,24 @@ impl CloneControl for FakeControl<'_> { table: &[HostMap], deadline: Instant, ) -> Result<(), Failure> { + // The framing sends the table in `MAPS` payloads, which the guest decodes. + let parts = encode_maps_payloads(table, OUTER_MAX_PAYLOAD - APP_HEADER_LEN) + .expect("the framing encodes the clone's table"); + let decoded: Vec = parts + .iter() + .flat_map(|part| decode_maps_payload(part).unwrap().maps) + .collect(); + let (applied, answer) = self + .maps + .pop_front() + .unwrap_or((decoded.len(), Answer::Done)); + self.applied + .extend_from_slice(&decoded[..applied.min(decoded.len())]); self.calls.push(Call::Map { session: session.number, - table: table.to_vec(), + table: decoded, deadline, }); - let (applied, answer) = self.maps.pop_front().unwrap_or((table.len(), Answer::Done)); - self.applied - .extend_from_slice(&table[..applied.min(table.len())]); answered(answer, Confirmed, deadline).map(|_| ()) } @@ -1406,7 +1421,10 @@ fn a_clone_with_an_export_gets_its_mapping_table_once_ready_and_its_start_once_i let started = activated.start(&mut control).unwrap(); // In the session in which the clone answered, the table follows its Ready, in order, and the // start follows once the guest applied every mapping, both by the Ready-to-Start deadline. + // The table's first mapping, the exported directory itself, keeps its empty source through + // the `MAPS` payloads that carry it. control.assert_activates_first(); + assert_eq!(data_table()[0].source, ""); assert_eq!( control.calls[2..], [ diff --git a/nvx-rs/src/protocol/maps.rs b/nvx-rs/src/protocol/maps.rs index a660e0e7d..f72d4353f 100644 --- a/nvx-rs/src/protocol/maps.rs +++ b/nvx-rs/src/protocol/maps.rs @@ -3,7 +3,10 @@ //! //! A payload holds the index of its first entry (`u32`) and its number of entries (`u32`), //! followed by the entries. Each entry holds its flags (`u16`), the lengths of its source and -//! target (`u16` each), and then both. +//! target (`u16` each), and then both. A source names a path relative to the guest's mount of the +//! host's export, and an empty source names the export's root itself, which is how the table of a +//! directory export maps the whole directory. NVX's managed agent, whose export is an aggregate, +//! whose root lists its children, refuses an empty source. use super::{ProtocolError, u16_at, u32_at, violation}; @@ -21,7 +24,8 @@ pub const MAP_READ_ONLY: u16 = 1 << 0; /// One bind mount of the host mapping table. #[derive(Debug, Clone, PartialEq, Eq)] pub struct HostMap { - /// Path of the mapped object relative to the guest's mount of the export, `/`-separated. + /// Path of the mapped object relative to the guest's mount of the export, `/`-separated, or + /// empty for the export's root itself. pub source: String, /// Absolute guest path at which the guest agent mounts it. pub target: String, @@ -35,9 +39,10 @@ pub struct HostMap { /// A payload holds the index of its first entry and its number of entries, so the guest agent /// applies the entries in order and only up to the number that the kernel command line /// announced. Each entry holds its flags, the lengths of its source and target, and then both. -/// The guest agent requires 1 to [`MAX_HOST_MAPPINGS`] entries, each with a relative source and -/// an absolute target of at most [`MAX_MAP_PATH_BYTES`] without NUL bytes, whose `/`-separated -/// components are neither empty, `.`, nor `..`. +/// The guest agent requires 1 to [`MAX_HOST_MAPPINGS`] entries, each with a source that is empty, +/// for the export's root, or relative, and an absolute target, each of at most +/// [`MAX_MAP_PATH_BYTES`] without NUL bytes, whose `/`-separated components are neither empty, +/// `.`, nor `..`. pub fn encode_maps_payloads(maps: &[HostMap], limit: usize) -> Result>, ProtocolError> { if maps.is_empty() || maps.len() > MAX_HOST_MAPPINGS { return Err(violation(format!( @@ -50,9 +55,9 @@ pub fn encode_maps_payloads(maps: &[HostMap], limit: usize) -> Result, /// Absolute path at which the guest bind-mounts the source. pub target: Vec, @@ -223,10 +228,11 @@ fn truncated() -> ProtocolError { violation("the host mapping request is truncated") } -/// Whether the guest agent accepts the paths of a mapping: a relative `source` and an absolute -/// `target` of at most [`MAX_MAP_PATH_BYTES`] without NUL characters, whose `/`-separated -/// components are neither empty, `.`, nor `..`. UTF-8 text follows these rules as its bytes, in -/// which `/` and NUL occur only as themselves. +/// Whether the guest agent accepts the paths of a mapping: a `source` that is empty, for the +/// export's root itself, or relative, and an absolute `target`, each of at most +/// [`MAX_MAP_PATH_BYTES`] without NUL characters, whose `/`-separated components are neither +/// empty, `.`, nor `..`. UTF-8 text follows these rules as its bytes, in which `/` and NUL occur +/// only as themselves. fn acceptable(source: &[u8], target: &[u8]) -> bool { let normal = |components: &[u8]| { !components.is_empty() @@ -237,6 +243,6 @@ fn acceptable(source: &[u8], target: &[u8]) -> bool { [source, target] .iter() .all(|path| path.len() <= MAX_MAP_PATH_BYTES && !path.contains(&0)) - && normal(source) + && (source.is_empty() || normal(source)) && target.strip_prefix(b"/").is_some_and(normal) } diff --git a/nvx-rs/src/protocol/tests.rs b/nvx-rs/src/protocol/tests.rs index 34297c9d6..ef40c29f3 100644 --- a/nvx-rs/src/protocol/tests.rs +++ b/nvx-rs/src/protocol/tests.rs @@ -222,10 +222,12 @@ fn exec_payloads_match_their_vectors() { } /// Each `MAPS` vector decodes, with the host's and with the guest's decoder, to its values, and the -/// host encodes a mapping table that holds its entries to its bytes. +/// host encodes a mapping table that holds its entries to its bytes: the root of the export, +/// which an empty source names, included. #[test] fn maps_payloads_match_their_vectors() { - for vector in of_kind(NVXC_V1, "MAPS", &["ONE_ENTRY", "TWO_ENTRIES", "CONTINUED"]) { + let names = ["ONE_ENTRY", "TWO_ENTRIES", "CONTINUED", "ROOT_SOURCE"]; + for vector in of_kind(NVXC_V1, "MAPS", &names) { let name = vector.name; let mut values = vector.values(); let first: u32 = values.number(); @@ -677,7 +679,6 @@ fn maps_payloads_enforce_agent_limits() { let limit = OUTER_MAX_PAYLOAD - APP_HEADER_LEN; let too_long = format!("/{}", "t".repeat(MAX_MAP_PATH_BYTES)); for invalid in [ - map("", "/w", false), map("/0", "/w", false), map("0", "w", false), map("0", &too_long, false), @@ -695,12 +696,22 @@ fn maps_payloads_enforce_agent_limits() { map("0", "/w/", false), map("0", "/tmp/../etc", false), map("0", "/w/./x", false), + // Only an empty source names the export's root, whose target is as any other's. + map("/", "/w", false), + map("", "", false), + map("", "w", false), + map("", "/", false), + map("", "/w/", false), ] { assert!( encode_maps_payloads(std::slice::from_ref(&invalid), limit).is_err(), "{invalid:?}" ); } + // An empty source names the export's root itself. + let root = map("", "/data", true); + let payloads = encode_maps_payloads(std::slice::from_ref(&root), limit).unwrap(); + assert_eq!(decode_maps_payload(&payloads[0]).unwrap().maps, [root]); // Names that merely start with dots are ordinary components. let dotted = map("0/.config", "/home/..data/...", false); let payloads = encode_maps_payloads(std::slice::from_ref(&dotted), limit).unwrap(); @@ -726,7 +737,6 @@ fn maps_decoders_refuse_the_invalid_vectors() { "TOO_MANY_ENTRIES", "TRAILING_BYTE", "UNKNOWN_FLAG", - "EMPTY_SOURCE", "DOT_SOURCE", "DOT_DOT_SOURCE", "NUL_SOURCE", @@ -1125,6 +1135,7 @@ fn guest_exec_decoder_refuses_what_the_c_agent_refused() { #[test] fn host_maps_encoding_round_trips_through_the_guest_decoder() { let maps = [ + map("", "/data", true), map("0/src", "/work", true), map("1/file", "/etc/value", false), ]; @@ -1134,6 +1145,11 @@ fn host_maps_encoding_round_trips_through_the_guest_decoder() { assert_eq!( decoded.maps, [ + GuestHostMap { + source: Vec::new(), + target: b"/data".to_vec(), + read_only: true, + }, GuestHostMap { source: b"0/src".to_vec(), target: b"/work".to_vec(), diff --git a/nvx-rs/tests/vectors/README.md b/nvx-rs/tests/vectors/README.md index d4ed4a2ac..e8c89dd8a 100644 --- a/nvx-rs/tests/vectors/README.md +++ b/nvx-rs/tests/vectors/README.md @@ -61,6 +61,12 @@ the key that the vector's bytes decode to. its own, such as `nvxc-v2.txt` for the exec v2 of [microsoft/nvx#623](https://github.com/microsoft/nvx/issues/623), and the bytes of an earlier version that it refuses become its rejection vectors. + Version 1 has one exception: `MAPS|ROOT_SOURCE` of `nvxc-v1.txt` was the + rejection vector `INVALID_MAPS|EMPTY_SOURCE` until an empty source came to + name the export's root. Only the clone of a template whose export is a + directory sends one, to the template's own guest, which deploys with the + host and whose build the template's key names, so the C agent of earlier + releases, which refuses it, never receives one. - The bytes come from the protocol's definition, never from the code that they check, and so do the values of each vector. diff --git a/nvx-rs/tests/vectors/nvxc-v1.txt b/nvx-rs/tests/vectors/nvxc-v1.txt index c8ec1380a..ee0dd21b7 100644 --- a/nvx-rs/tests/vectors/nvxc-v1.txt +++ b/nvx-rs/tests/vectors/nvxc-v1.txt @@ -51,6 +51,10 @@ EXEC|EMPTY_ENVIRONMENT|00000000010001000200000000000000080000002f62696e2f656e76| MAPS|ONE_ENTRY|0000000001000000000001000200302f77|0|1|RW|0|/w MAPS|TWO_ENTRIES|0000000002000000000001000200302f77010005000400312f6120622f742f78|0|2|RW|0|/w|RO|1/a b|/t/x MAPS|CONTINUED|0300000001000000000001000200352f77|3|1|RW|5|/w +# An empty source, which names the export's root itself, as the table of a directory export maps +# the whole directory. It was the rejection vector EMPTY_SOURCE until then; README.md's rules say +# why it could move. +MAPS|ROOT_SOURCE|00000000010000000000000002002f77|0|1|RW||/w # Every feature of version 1. Bit 1 is retired, and no guest sets it. FEATURES|ALL|7d000000|0x7d @@ -63,9 +67,8 @@ INVALID_MAPS|TOO_MANY_ENTRIES|0000000001100000 INVALID_MAPS|TRAILING_BYTE|0000000001000000000001000200302f7700 # An unknown flag. INVALID_MAPS|UNKNOWN_FLAG|0000000001000000020001000200302f77 -# Sources that are empty, have a `.` or `..` component, or hold a NUL byte, and one that is not -# UTF-8, which only decoders that require UTF-8 refuse, before a target that is missing. -INVALID_MAPS|EMPTY_SOURCE|00000000010000000000000002002f77 +# Sources that have a `.` or `..` component, or hold a NUL byte, and one that is not UTF-8, which +# only decoders that require UTF-8 refuse, before a target that is missing. INVALID_MAPS|DOT_SOURCE|00000000010000000000010002002e2f77 INVALID_MAPS|DOT_DOT_SOURCE|0000000001000000000004000200302f2e2e2f77 INVALID_MAPS|NUL_SOURCE|0000000001000000000001000200002f77 diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index ff91482cd..7a253db91 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -182,9 +182,10 @@ table of the part's first mapping, then the number of its mappings, each a little-endian `u32`, then each mapping: its flags, a little-endian `u16` that is 1 for a read-only mapping and 0 otherwise, then the lengths of its source and its target, each a little-endian `u16`, then the source, a path relative -to the guest's mount of the host's export, and the target, an absolute path -in the root. `root::encode_maps` splits a table into parts of at most 256 KiB, -and an empty table into none. `root::decode_maps` reads a part with +to the guest's mount of the host's export, or empty for the export's root +itself, and the target, an absolute path in the root. `root::encode_maps` +splits a table into parts of at most 256 KiB, and an empty table into none. +`root::decode_maps` reads a part with `nvx::protocol::decode_guest_maps_payload`, which takes the paths as bytes, as Linux does, and refuses what that decoder refuses, a part without a mapping, and a part that would end the table past 4,096 mappings. diff --git a/tests/runtime-adapter/tests/host_session.rs b/tests/runtime-adapter/tests/host_session.rs index 83f35dd25..05f261e21 100644 --- a/tests/runtime-adapter/tests/host_session.rs +++ b/tests/runtime-adapter/tests/host_session.rs @@ -1993,6 +1993,45 @@ fn a_clone_s_order_ends_its_table_at_a_mapping_that_the_guest_fails_and_stops_th assert!(broker.read().is_none(), "the host sent more"); } +#[test] +fn a_clone_s_order_maps_the_root_of_its_export_with_an_empty_source() { + // The table of a directory export maps the directory itself with an empty source, which the + // order sends in its part, as the guest decodes it, and which the guest applies. + let table = vec![ + mapping("", "/data", true), + mapping("work", "/data/work", false), + ]; + let parts = encode_maps(&table).unwrap(); + assert_eq!(parts.len(), 1); + let decoded = decode_maps(&parts[0]).unwrap(); + assert_eq!(decoded.first, 0); + assert_eq!( + decoded + .maps + .iter() + .map(|map| (map.source.as_slice(), map.target.as_slice(), map.read_only)) + .collect::>(), + [ + (b"".as_slice(), b"/data".as_slice(), true), + (b"work".as_slice(), b"/data/work".as_slice(), false), + ] + ); + let (mut broker, client) = attached(FIRST, usize::MAX); + // The table follows the clone's `Ready`, which answers its record. + let activation = activating(client); + broker.expect(Message::Activate(activation_record()), &clone_ready()); + let (client, activated) = joined(activation); + assert_eq!(activated.unwrap(), Some(clone_activation())); + let mapping = spawn(client, move |client| { + clone_order().map(client, &table, deadline()) + }); + broker.expect(Message::Maps(parts[0].clone()), &Message::Mapped); + let (client, mapped) = joined(mapping); + mapped.unwrap(); + drop(client); + assert!(broker.read().is_none(), "the host sent more"); +} + #[test] fn a_clone_s_session_that_ends_after_its_bootstrap_is_lost_and_the_next_takes_the_same_record() { let next = Identity { epoch: 2, ..FIRST }; From ddc376178ce24d04399292cd251e350faafc4efb Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sun, 25 Oct 2026 20:50:21 -0700 Subject: [PATCH 3/5] runtime-adapter: let a clone's start share host paths with its root `runtime-adapter-host start` takes the shares of a launch, `--share HOST:TARGET[:ro]`, `--deny`, `--reserved`, and `--share-root`, with the same rules: `--deny`, `--reserved`, and `--share-root` need a `--share`. `clone::Request` carries them as `sharing::HostFsSharing`, and `Request::export` plans their export as a launch plans it, with NVX's `filesystem::plan` for the container root, so the start refuses shares that it cannot export, with status 1, before it reads the template store, needs NVX's artifacts, or creates the sandbox's store. `Request::clone_request` takes the planned export into NVX's `CloneRequest`, whose `ClonePlan` takes only an export of one directory, which the clone's restore binds to the template's dormant filesystem slot, and refuses others, as a failure of the request, before any VMM starts. The restore sequence sends the export's mapping table through the adapter's clone order once the clone is ready, and `mapped` now prints the number of mappings that the clone's activation record announces, which the guest applied before its sandbox started. Tests without a hypervisor cover the new usage errors, the refusals before the template store, and the export that NVX's request carries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 34e6ffe3-3ebf-42db-bfca-e0ecfade6873 --- tests/runtime-adapter/README.md | 94 ++++++---- .../src/bin/runtime-adapter-host.rs | 136 ++++++++------ tests/runtime-adapter/src/clone.rs | 40 +++- tests/runtime-adapter/tests/clone_start.rs | 171 ++++++++++++++++-- tests/runtime-adapter/tests/vm_clone.rs | 4 +- 5 files changed, 340 insertions(+), 105 deletions(-) diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index 7a253db91..96c76ee63 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -1251,7 +1251,8 @@ and its directories and files, and the paths that the guest could move inside the parts that it may write, still name what they named when the export was planned. Once the guest is ready, the launch sends the table, `LayeredLaunch::table`, in the [host's order](#the-hosts-order): each mapping -before those inside its target. +before those inside its target. A [clone's](#clones) start takes the same +shares, whose export NVX binds to the template's dormant filesystem slot. ### Networks @@ -1402,9 +1403,12 @@ library side, `clone`, holds what the command's options choose of the clone, 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, its scratch's journal policy, the bound -of a GPT disk's first partition, and the machine ID that its executions see. +of a GPT disk's first partition, the machine ID that its executions see, and +the host paths that it shares with its root, as a launch's +[shares](#shares). `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 +root, `Request::export` plans the export of the shares as a launch plans it, +and `Request::clone_request` returns NVX's `CloneRequest` of both, 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 hostname `runtime-adapter`, which every execution sees, the image's own machine @@ -1417,18 +1421,19 @@ 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 -target is then, 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 a -malformed generation, which quarantines it, or to the host, as it does for -every clone. The store then leases the generation to the sandbox, -`Store::acquire`, at the host's time, which it refuses for a generation that a -tombstone quarantines, or that is not eligible then, by the monotonic clock of -the host's boot that captured the template, and by UTC otherwise. For tests, -the host's time may come from the variable `RUNTIME_ADAPTER_HOST_TIME`, as a -clock fixture, such as another boot's, in the form that `clone::host_time_text` -writes: +The command first plans the export of the clone's shares, if it has any, and +refuses those that it cannot export, as a launch refuses them, then reads the +generation from the store's index, and, unless the request names the clone's +memory target, its compatibility key, whose RAM the target is then, 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 a malformed generation, which +quarantines it, or to the host, as it does for every clone. The store then +leases the generation to the sandbox, `Store::acquire`, at the host's time, +which it refuses for a generation that a tombstone quarantines, or that is not +eligible then, by the monotonic clock of the host's boot that captured the +template, and by UTC otherwise. For tests, the host's time may come from the +variable `RUNTIME_ADAPTER_HOST_TIME`, as a clock fixture, such as another +boot's, in the form that `clone::host_time_text` writes: `host=HOST,boot=BOOT,clock=CLOCK,monotonic=NANOSECONDS,utc=NANOSECONDS`, with the identities of the host and of its boot, 32 lowercase hexadecimal digits each, the clock as a manifest spells it, `linux-boottime` or @@ -1440,19 +1445,37 @@ NVX's `ClonePlan` then checks the clone against the generation from the metadata of its files alone, under the sandbox's lock, which the lease holds: the bindings of its images, which no one else may change, the geometry of its image, which must be the placeholder's, its length included, and that of its -scratch image, which must be the template's scratch's, and its memory target, -whole blocks of 128 MiB, from the template's RAM to its capacity. A clone that +scratch image, which must be the template's scratch's, its memory target, +whole blocks of 128 MiB, from the template's RAM to its capacity, and its +export, if it has one: the template's filesystem slot, which its capture left +dormant, binds one directory, so the export must have exactly one, the +export's root, with any other shares inside it, and the directory may neither +lie inside the template store's directory nor hold it, so that no clone +reaches the payload of a generation. A clone that the template cannot serve fails there, before any VMM starts. The command then records the clone in the sandbox's store, as `clone::CloneRecord`, in the format of a launch's [record](#layered-roots), in the mode `clone`, with the template's kernel command line, the plan and the bindings, so that `probe` and `exec` read the plan back, no root configuration, and the template store and the generation, so -that a later stop finds the store whose lease it releases. OpenVMM restores the +that a later stop finds the store whose lease it releases. Once NVX checked +the export again, with `ExportPlan::verify`, OpenVMM restores the generation's snapshot in place, through NVX's supervisor, as the launch that the -lease reserves, and the restore sequence activates the clone through the +lease reserves, with the export's directory on the template's filesystem slot, +and the restore sequence activates the clone through the adapter's clone order, `host::CloneOrder`, which sends the clone's activation -record in an `Activate`, checks the guest agent's build in its `Ready`, and -sends `Start`, with no mapping table: the command maps nothing into a clone. +record in an `Activate`, checks the guest agent's build in its `Ready`, sends +the export's mapping table, which the record announces, in order, as a launch +sends its own, and then `Start`. The record names the export by the identity of +its plan, and the table holds its mappings, of the directory itself, from an +empty source, and of the paths inside it. A clone without shares has no +export: its restore leaves the slot dormant, its record announces no mapping, +and the sequence sends no table. If the guest fails a mapping, the sequence +sends no more of the table, nor `Start`, and the clone fails, once the clone +order asked the guest to stop, which tears down the root. The guest's +`MapFailed` does not tell the request's mappings from a failure of its own, so +the clone order takes it for neither, and NVX attributes the failure to what +the host saw of it: a broken protocol, unless the deadline passed or the +session was lost. A clone that fails, from its plan to its commit, fails with NVX's `CloneFailure`, which attributes the failure, by the evidence that the host saw @@ -1607,7 +1630,7 @@ 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] [--first-partition-bound BYTES] [--machine-id (image | generated)]` +- `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] [--machine-id (image | generated)] [SHARES]` 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 @@ -1622,8 +1645,12 @@ the directory that `NVX_ARTIFACTS_DIR` names, and the guest agent's build from partition, 64 KiB by default, as a launch's guest takes it. Every execution of the clone sees the hostname `runtime-adapter`, and the machine ID that `--machine-id` chooses: the image's own, `image`, by default, or one that the - guest generates once for the clone, `generated`. - Once the restore sequence started the clone's sandbox, it exchanges a `Ping` + guest generates once for the clone, `generated`. SHARES, as `launch` takes + them, share host paths with the clone's root: the start refuses those that it + cannot export before anything else, and NVX's plan those that the + template's filesystem slot cannot bind, as [Clones](#clones) describes. + Once the restore sequence sent the clone's mapping table, if it has one, and + 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 fails, such as one whose activation failed before it was ready, and whose VM @@ -2104,20 +2131,25 @@ 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, a first-partition bound for an EROFS - image, invalid values, an option given twice, 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; + image, invalid values, an option given twice, a share's options without a + share, 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; + generation, whose image the plan refuses, and whose shares it cannot export, + each with status 1 before it needs NVX's artifacts or creates the sandbox's + store, and the shares before it reads the template store; - the text form of a host's time, which `RUNTIME_ADAPTER_HOST_TIME` takes, written and read back on both clocks, and the texts that are none; - a clone's record: its JSON, with the plan that `layered::LaunchRecord` reads back, the template store and the generation that a stop reads, a launch's record that is no clone's, and records that a reader refuses; - NVX's request of a clone, with the staged bindings, the memory target, and - the guest's defaults or the request's settings, and the images that the - sandbox's store does not stage, which it refuses. + the guest's defaults or the request's settings, the images that the + sandbox's store does not stage, which it refuses, and the export of the + request's shares: none without shares, and with them, one directory, the + root of a read-only share, holding a read-write one, mapped from an empty + source and from its path. `tests/initramfs.rs` and `tests/runtime.rs` check, in the host-only configuration, on Linux and Windows: diff --git a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs index da38cd633..235b51309 100644 --- a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs +++ b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs @@ -98,39 +98,51 @@ //! - `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] [--first-partition-bound BYTES] -//! [--machine-id (image | generated)]` 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. +//! [--machine-id (image | generated)] [SHARES]` 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`. SHARES, as `launch` takes them, share +//! host paths with the clone's root: the command plans their export as a launch does, with +//! NVX's `filesystem::plan`, and refuses shares that it cannot export before anything else. It +//! then 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, its memory target, in whole blocks, from the template's RAM to its +//! capacity, and its export, if it has one, which must export exactly one directory, which +//! neither lies inside the template store's directory nor holds it: the export's root, which +//! the clone's restore binds to the template's dormant filesystem slot. //! 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 -//! place, with the clone's devices, through NVX's supervisor, as the launch that the lease -//! reserves, and the restore sequence of `nvx::host::template` activates the clone through the -//! 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, 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. Every -//! execution of the clone sees the hostname `runtime-adapter`, and the machine ID that -//! `--machine-id` chooses: the image's own, `image`, by default, or one that the guest generates -//! once for the clone, `generated`, which a read-only bind holds at `/etc/machine-id`. 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. +//! `runtime_adapter::clone::CloneRecord`. Once NVX checked the export again, with +//! `ExportPlan::verify`, OpenVMM restores the generation's snapshot in place, with the clone's +//! devices, through NVX's supervisor, as the launch that the lease reserves, and the restore +//! sequence of `nvx::host::template` activates the clone through the 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, the overlay mode MODE and the scratch's journal policy +//! POLICY, as a launch takes them, for a GPT disk, the bound BYTES of its first partition, 64 +//! KiB unless given, as a launch's guest takes it by default, and the export, by the identity of +//! its plan, with the number of its mappings. Every execution of the clone sees the hostname +//! `runtime-adapter`, and the machine ID that `--machine-id` chooses: the image's own, `image`, +//! by default, or one that the guest generates once for the clone, `generated`, which a +//! read-only bind holds at `/etc/machine-id`. 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 sequence sends the export's mapping table, in order, as a launch sends +//! its own; a clone without shares has no export: its restore leaves the slot dormant, and the +//! sequence sends no table. If the guest fails a mapping, the sequence sends no more of the +//! table, nor `Start`, and ends the clone, whose guest the clone order asks to stop, which tears +//! down the root, and which then fails as below. Once every mapping applied, the sequence +//! starts the clone's sandbox, and the command 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 @@ -297,6 +309,7 @@ use std::time::{Duration, Instant}; use nvx::activation::{MachineIdMode, StoreGeneration}; use nvx::exec::{Cause, Key, Limits, Outcome, Status, Stream, Timers, Window}; use nvx::host::artifacts::{Artifacts, absolute}; +use nvx::host::filesystem::ExportPlan; use nvx::host::instance::generate_capability; use nvx::host::launch::{DEFAULT_MEMORY_MIB, LaunchSpec}; use nvx::host::nvxs::HostConnectionError; @@ -419,6 +432,8 @@ const USAGE: &str = concat!( " [--workload-identity UID:GID] [--overlay-mode MODE]", " [--scratch-journal POLICY]\n", " [--first-partition-bound BYTES] [--machine-id (image | generated)]\n", + " [--share HOST:TARGET[:ro]... [--deny PATH]... [--reserved TARGET]...", + " [--share-root DIR]...]\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,...]", @@ -826,17 +841,7 @@ fn parse(arguments: Vec) -> Result { } } "launch" => { - let sharing = &options.sharing; - let share_options = [ - ("--deny", !sharing.denied().is_empty()), - ("--reserved", !sharing.reserved().is_empty()), - ("--share-root", !sharing.roots().is_empty()), - ]; - if sharing.shares().is_empty() - && let Some((option, _)) = share_options.iter().find(|(_, given)| *given) - { - return Err(format!("{option} needs --share")); - } + check_shares(&options.sharing)?; let layered = match (options.distro, options.identity, options.upper) { (Some(_), None, _) => return Err("--distro needs --distro-identity".to_owned()), (Some(_), Some(_), None) => return Err("--distro needs --upper".to_owned()), @@ -934,6 +939,7 @@ fn parse(arguments: Vec) -> Result { if format == ImageFormat::Erofs && options.first_partition_bound.is_some() { return Err("--first-partition-bound needs --distro gpt:PATH".to_owned()); } + check_shares(&options.sharing)?; Command::Start { store, guest_build, @@ -951,6 +957,7 @@ fn parse(arguments: Vec) -> Result { scratch_journal: options.scratch_journal, first_partition_bound: options.first_partition_bound, machine_id: options.machine_id.unwrap_or(MachineIdMode::Image), + sharing: options.sharing, }, }), time: host_time_from_env()?, @@ -1074,7 +1081,7 @@ fn parse_option( ) })?); } - ("launch", "--share") => { + ("launch" | "start", "--share") => { let value = text(value)?; let share = Share::parse(&value).ok_or_else(|| { format!( @@ -1084,9 +1091,9 @@ fn parse_option( })?; options.sharing.add_share(share); } - ("launch", "--deny") => options.sharing.add_denied(value), - ("launch", "--reserved") => options.sharing.add_reserved(text(value)?), - ("launch", "--share-root") => options.sharing.add_root(value), + ("launch" | "start", "--deny") => options.sharing.add_denied(value), + ("launch" | "start", "--reserved") => options.sharing.add_reserved(text(value)?), + ("launch" | "start", "--share-root") => options.sharing.add_root(value), ("launch" | "create", "--network") if options.network.is_none() => { options.network = Some(PathBuf::from(value)); } @@ -1139,6 +1146,22 @@ fn parse_option( Ok(()) } +/// Says why the options of `sharing`, of a launch or a start, are not a command's: `--deny`, +/// `--reserved`, or `--share-root` without a `--share`. +fn check_shares(sharing: &HostFsSharing) -> Result<(), String> { + let options = [ + ("--deny", !sharing.denied().is_empty()), + ("--reserved", !sharing.reserved().is_empty()), + ("--share-root", !sharing.roots().is_empty()), + ]; + if sharing.shares().is_empty() + && let Some((option, _)) = options.iter().find(|(_, given)| *given) + { + return Err(format!("{option} needs --share")); + } + Ok(()) +} + /// Parses the arguments of `exec` after its sandbox store `store`: its executions, each ended by /// a lone `;` or the end of the arguments, with the options before them, or says why they are not /// the command's. @@ -1762,9 +1785,11 @@ fn start( clone: &CloneOptions, time: Option, ) -> Result { - // A request that the adapter's plan refuses, and a generation that the template store does not - // hold, fail the start before it needs NVX's artifacts or the sandbox's store. + // A request that the adapter's plan refuses, shares that it cannot export, and a generation + // that the template store does not hold, fail the start before it needs NVX's artifacts or + // the sandbox's store. let plan = clone.request.plan().map_err(|error| describe(&error))?; + let export = clone.request.export().map_err(|error| describe(&error))?; let templates = Store::open(&clone.templates).map_err(|error| describe(&error))?; let generation = templates .index() @@ -1815,6 +1840,7 @@ fn start( templates: &templates, request: &clone.request, plan, + export, memory, runtime: &runtime, guest_build, @@ -1849,6 +1875,8 @@ struct CloneLaunch<'a> { request: &'a runtime_adapter::clone::Request, /// The adapter's plan of the clone's image over its upper. plan: nvx::host::sandbox::SandboxPlan, + /// The export of the host paths that the clone shares with its root, if any. + export: Option, /// The clone's memory target, in bytes, or none if the request names none, and the /// generation's key, whose RAM it would be, cannot be read. memory: Option, @@ -1860,8 +1888,9 @@ struct CloneLaunch<'a> { impl CloneLaunch<'_> { /// Plans, records, and launches the clone in the sandbox of `acquired`, which leased the - /// generation, activates it, starts its sandbox, exchanges a `Ping` and the longest `Echo` - /// with its guest agent, and commits the launch, leaving the clone running. + /// generation, activates it, maps its export's table, if it has one, starts its sandbox, + /// exchanges a `Ping` and the longest `Echo` with its guest agent, and commits the launch, + /// leaving the clone running. /// /// Fails with the clone's failure, as NVX's plan, launch, restore sequence, and commit return /// it, or as `Started::fail` records it for a guest agent that does not answer the command @@ -1873,6 +1902,7 @@ impl CloneLaunch<'_> { templates, request, plan, + export, memory, runtime, guest_build, @@ -1883,7 +1913,7 @@ impl CloneLaunch<'_> { // No template serves a memory target of 0, which stands for the one of a key that could // not be read: the plan fails as it reads the key again, before it checks the target. let clone_request = request - .clone_request(plan.clone(), &bindings, memory.unwrap_or(0)) + .clone_request(plan.clone(), export, &bindings, memory.unwrap_or(0)) .map_err(|error| Failed::Command(binding_error(&error)))?; let clone_plan = ClonePlan::new( templates, @@ -1892,6 +1922,8 @@ impl CloneLaunch<'_> { Instant::now() + LOCK_TIMEOUT, ) .map_err(Failed::Clone)?; + // The mappings that the clone's record announces, which the restore sequence sends. + let mappings = clone_plan.activation().configuration().mappings; let record = CloneRecord { template: templates.dir(), generation: acquired.lease.generation(), @@ -1915,7 +1947,7 @@ impl CloneLaunch<'_> { println!("build={}", escape(guest_build)); let mut started = activated.start(&mut order).map_err(Failed::Clone)?; println!("table=applied"); - println!("mapped=0"); + println!("mapped={mappings}"); println!("sandbox=started"); let deadline = Instant::now() + SESSION_TIMEOUT; let client = started.session(); diff --git a/tests/runtime-adapter/src/clone.rs b/tests/runtime-adapter/src/clone.rs index ef85bb562..0587fdd1d 100644 --- a/tests/runtime-adapter/src/clone.rs +++ b/tests/runtime-adapter/src/clone.rs @@ -4,13 +4,17 @@ //! 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, its scratch's journal policy, the bound of a GPT disk's first partition, and the -//! machine ID that its executions see, the image's own or one that its guest generates. -//! [`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 +//! overlay mode, its scratch's journal policy, the bound of a GPT disk's first partition, the +//! machine ID that its executions see, the image's own or one that its guest generates, and the +//! host paths that it shares with its root, as a launch shares them, `sharing::HostFsSharing`. +//! [`Request::plan`] plans the image over the upper as a launch plans a layered root, +//! [`Request::export`] plans the export of the shares as a launch plans it, and +//! [`Request::clone_request`] returns NVX's `nvx::host::template::CloneRequest` of both, 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. +//! leased generation before any VMM starts, from the metadata of the clone's files alone, and +//! takes only an export of one directory, which the clone's restore binds to the template's +//! dormant filesystem slot. //! //! 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 @@ -30,6 +34,7 @@ use nvx::activation::{ Journal, MachineIdMode, OverlayMode as RecordOverlay, StoreGeneration, WorkloadIdentity, WorkloadView, }; +use nvx::host::filesystem::ExportPlan; use nvx::host::process::BootId; use nvx::host::sandbox::SandboxPlan; use nvx::host::sandbox::distro::DISTRO_POLICY_REVISION; @@ -41,6 +46,7 @@ use serde_json::{Value, json}; use crate::layered::{Bindings, RECORD_FORMAT, RECORD_NAME, plan, unstaged, write_record}; use crate::root::{DEFAULT_FIRST_PARTITION_BOUND, JournalPolicy, OverlayMode}; +use crate::sharing::HostFsSharing; use crate::staged::invalid; /// The mode of a clone's [record](CloneRecord). @@ -84,6 +90,8 @@ pub struct Request { /// The machine ID that the clone's executions see: the image's own, as a launch's do, or one /// that the clone's guest generates. pub machine_id: MachineIdMode, + /// The host paths that the clone shares with its root, as a launch shares them, if any. + pub sharing: HostFsSharing, } impl Request { @@ -103,9 +111,22 @@ impl Request { ) } - /// Returns NVX's request of the clone of `plan`, the [plan](Self::plan) of this request, with - /// the bindings that the sandbox's store stages for its images, `bindings`, as - /// `staged::Staged::bindings` finds them, and the memory target `memory`, in bytes. + /// Plans the export of the host paths that the clone shares with its root, as a launch plans + /// it, with `sharing::HostFsSharing::plan`, or returns `None` for a clone that shares none, + /// whose restore leaves the template's filesystem slot dormant. + /// + /// Fails as `HostFsSharing::plan` does, such as for a share outside every host directory that + /// the adapter authorizes. The plan of an export that the template's dormant filesystem slot + /// cannot bind, of more than one directory or file, or of a file, is NVX's `ClonePlan` to + /// refuse. + pub fn export(&self) -> Result> { + self.sharing.plan() + } + + /// Returns NVX's request of the clone of `plan`, the [plan](Self::plan) of this request, and + /// of `export`, the [export](Self::export) of its shares, if any, with the bindings that the + /// sandbox's store stages for its images, `bindings`, as `staged::Staged::bindings` finds + /// them, and the memory target `memory`, in bytes. /// /// The record of the clone's activation names the workload identity, if any, the /// [`CLONE_HOSTNAME`], the request's machine ID, the overlay's mode, and the scratch's @@ -119,6 +140,7 @@ impl Request { pub fn clone_request( &self, plan: SandboxPlan, + export: Option, bindings: &Bindings, memory: u64, ) -> Result { @@ -148,7 +170,7 @@ impl Request { binding, policy_revision: DISTRO_POLICY_REVISION, scratch, - export: None, + export, memory, workload: WorkloadView { hostname: CLONE_HOSTNAME.as_bytes().to_vec(), diff --git a/tests/runtime-adapter/tests/clone_start.rs b/tests/runtime-adapter/tests/clone_start.rs index 94dcc6a46..fe2556802 100644 --- a/tests/runtime-adapter/tests/clone_start.rs +++ b/tests/runtime-adapter/tests/clone_start.rs @@ -1,9 +1,10 @@ //! The host program's `start` refuses options that do not make a start, and a clock fixture that is -//! not a host's time, as usage errors, and a generation that the template store does not hold, or -//! a template store that does not exist, before it needs NVX's artifacts or creates the sandbox's -//! store. A clone's record names its template store and its generation, which a stop reads back, -//! beside the plan that the other commands read, and a host's time keeps its text form, the form -//! of the clock fixture. The tests need no hypervisor. +//! not a host's time, as usage errors, and shares that it cannot export, a generation that the +//! template store does not hold, or a template store that does not exist, before it needs NVX's +//! artifacts or creates the sandbox's store. A clone's record names its template store and its +//! generation, which a stop reads back, beside the plan that the other commands read, a clone's +//! request carries the export of its shares, and a host's time keeps its text form, the form of +//! the clock fixture. The tests need no hypervisor. #![cfg(feature = "host")] use std::fs; @@ -21,6 +22,7 @@ use runtime_adapter::clone::{ }; use runtime_adapter::layered::{Bindings, LaunchRecord, RECORD_FORMAT, RECORD_NAME}; use runtime_adapter::root::DEFAULT_FIRST_PARTITION_BOUND; +use runtime_adapter::sharing::{HostFsSharing, Share}; use runtime_adapter::staged::Staged; use serde_json::{Value, json}; @@ -180,13 +182,38 @@ fn the_host_program_refuses_options_that_do_not_make_a_start() { vec!["stop", "store", "--memory", "512"], "unexpected option \"--memory\" of stop", ), + // A start takes the shares of a launch, with the same rules. + ( + with(&["--upper", "ram", "--deny", "secret"]), + "--deny needs --share", + ), + ( + with(&["--upper", "ram", "--reserved", "/opt/adapter"]), + "--reserved needs --share", + ), + ( + with(&["--upper", "ram", "--share-root", "sources"]), + "--share-root needs --share", + ), + ( + with(&["--upper", "ram", "--share", ":/b:ro"]), + "--share takes HOST:TARGET or HOST:TARGET:ro, with an absolute guest path TARGET, \ + not \":/b:ro\"", + ), + ( + with(&["--upper", "ram", "--share", "a"]), + "--share takes HOST:TARGET or HOST:TARGET:ro", + ), + ( + vec!["stop", "store", "--share", "a:/b"], + "unexpected option \"--share\" of stop", + ), ]; // Each option that a start does not take. - let others: [(&str, &str); 7] = [ + let others: [(&str, &str); 6] = [ ("--guest-agent", "agent"), ("--hypervisor", "whp"), ("--runtime-dir", "/run/a"), - ("--share", "a:/b"), ("--network", "network.json"), ("--cpu-profile", "auto"), ("--memory-capacity", "512"), @@ -370,6 +397,7 @@ fn a_clone_s_record_names_its_template_store_and_generation_beside_its_plan() { scratch_journal: None, first_partition_bound: None, machine_id: MachineIdMode::Image, + sharing: HostFsSharing::default(), }; let plan = request.plan().unwrap(); let staged = Staged::stage(&distro, ImageFormat::Erofs, identity(), Some(&scratch)).unwrap(); @@ -459,17 +487,19 @@ fn a_clone_s_request_takes_the_staged_bindings_and_the_guest_s_defaults() { scratch_journal: None, first_partition_bound: None, machine_id: MachineIdMode::Image, + sharing: HostFsSharing::default(), }; let plan = request.plan().unwrap(); let staged = Staged::stage(&distro, ImageFormat::Erofs, identity(), Some(&scratch)).unwrap(); let bindings = staged.bindings(&plan); let memory = 512 << 20; let clone = request - .clone_request(plan.clone(), &bindings, memory) + .clone_request(plan.clone(), None, &bindings, memory) .unwrap(); assert_eq!(clone.adapter, plan); assert_eq!(clone.binding, staged.distro.binding); assert_eq!(clone.scratch, Some(staged.scratch.unwrap().binding)); + assert_eq!(clone.export, None); assert_eq!(clone.memory, memory); assert_eq!( clone.policy_revision, @@ -492,7 +522,7 @@ fn a_clone_s_request_takes_the_staged_bindings_and_the_guest_s_defaults() { ..request.clone() }; let clone = named - .clone_request(plan.clone(), &bindings, memory) + .clone_request(plan.clone(), None, &bindings, memory) .unwrap(); assert_eq!( clone.workload.identity, @@ -519,7 +549,7 @@ fn a_clone_s_request_takes_the_staged_bindings_and_the_guest_s_defaults() { ), ] { let error = request - .clone_request(plan.clone(), &bindings, memory) + .clone_request(plan.clone(), None, &bindings, memory) .unwrap_err(); assert_eq!(error.kind(), nvx::ErrorKind::Unavailable, "{what}"); assert!( @@ -537,13 +567,130 @@ fn a_clone_s_request_takes_the_staged_bindings_and_the_guest_s_defaults() { }; let plan = in_ram.plan().unwrap(); let error = in_ram - .clone_request(plan.clone(), &Bindings::default(), memory) + .clone_request(plan.clone(), None, &Bindings::default(), memory) .unwrap_err(); assert_eq!(error.kind(), nvx::ErrorKind::Unavailable); let staged = Staged::stage(&distro, ImageFormat::Erofs, identity(), None).unwrap(); let clone = in_ram - .clone_request(plan.clone(), &staged.bindings(&plan), memory) + .clone_request(plan.clone(), None, &staged.bindings(&plan), memory) .unwrap(); assert_eq!(clone.scratch, None); assert_eq!(clone.adapter, plan); + + // A request without shares plans no export. One with shares plans their export as a launch + // does, of one directory here, the root of the read-only share, which holds the other, and + // NVX's request carries the export that it is given. + assert_eq!(in_ram.export().unwrap(), None); + let sources = directory.join("sources"); + fs::create_dir_all(sources.join("out")).unwrap(); + let mut sharing = HostFsSharing::default(); + sharing.add_share(Share::new(sources.clone(), "/srv/data", true)); + sharing.add_share(Share::new(sources.join("out"), "/srv/out", false)); + sharing.add_root(directory.clone()); + let shared = Request { sharing, ..in_ram }; + let export = shared.export().unwrap().expect("the shares have an export"); + assert_eq!(export.children.len(), 1, "{export:?}"); + assert!(!export.children[0].file, "{export:?}"); + let binds = export + .binds + .iter() + .map(|bind| (bind.source.as_str(), bind.target.as_str(), bind.read_only)) + .collect::>(); + assert_eq!( + binds, + [("", "/srv/data", true), ("out", "/srv/out", false)], + "{export:?}" + ); + let clone = shared + .clone_request( + plan.clone(), + Some(export.clone()), + &staged.bindings(&plan), + memory, + ) + .unwrap(); + assert_eq!(clone.export, Some(export)); +} + +#[test] +fn a_start_refuses_shares_that_it_cannot_export_before_anything_else() { + // The start plans the export of its shares before it reads the template store, which does + // not exist, or needs NVX's artifacts, which the commands do not name, and before it creates + // the sandbox's store. + let directory = directory("start-unshared"); + let distro = image(directory.join("distro.erofs"), 4096); + let distro = format!("erofs:{}", distro.display()); + let sources = directory.join("sources"); + let outside = directory.join("outside"); + for path in [&sources, &outside] { + fs::create_dir(path).unwrap(); + } + let templates = directory.join("templates"); + let store = directory.join("store"); + let missing = directory.join("sources").join("missing"); + let inner = directory.join("sources").join("inner"); + fs::create_dir(&inner).unwrap(); + let (sources, outside) = (sources.to_str().unwrap(), outside.to_str().unwrap()); + let cases: [(Vec, String); 5] = [ + ( + vec![format!("{outside}:/srv/data")], + format!( + "--share entry {outside} lies outside the host directories that --share-root \ + authorizes" + ), + ), + ( + vec![format!("{sources}:/proc/data")], + "has the guest target /proc/data, which would cover the guest's /proc".to_owned(), + ), + ( + vec![ + format!("{sources}:/srv/data"), + "--share".to_owned(), + format!("{}:/srv/data/inner:ro", inner.display()), + ], + "where the host cannot keep it read-only".to_owned(), + ), + ( + vec![ + format!("{sources}:/srv/data"), + "--reserved".to_owned(), + "srv".to_owned(), + ], + "--reserved entry \"srv\" is not absolute".to_owned(), + ), + ( + vec![format!("{}:/srv/missing", missing.display())], + format!("--share entry {} is not accessible", missing.display()), + ), + ]; + for (shares, problem) in cases { + let mut arguments = vec![ + "start".to_owned(), + store.to_str().unwrap().to_owned(), + "--template".to_owned(), + templates.to_str().unwrap().to_owned(), + "--generation".to_owned(), + "1".to_owned(), + "--distro".to_owned(), + distro.clone(), + "--distro-identity".to_owned(), + IDENTITY.to_owned(), + "--upper".to_owned(), + "ram".to_owned(), + "--share-root".to_owned(), + sources.to_owned(), + "--share".to_owned(), + ]; + arguments.extend(shares); + let arguments = arguments.iter().map(String::as_str).collect::>(); + let output = host(&arguments, None); + assert_eq!(output.status.code(), Some(1), "{arguments:?}: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains(&problem), "{arguments:?}: {stderr}"); + assert!(!stderr.contains("template store"), "{stderr}"); + assert!(!stderr.contains("usage:"), "{stderr}"); + assert!(!store.exists(), "the start created the sandbox's store"); + assert!(!templates.exists()); + } } diff --git a/tests/runtime-adapter/tests/vm_clone.rs b/tests/runtime-adapter/tests/vm_clone.rs index 987df2adb..d75cc207d 100644 --- a/tests/runtime-adapter/tests/vm_clone.rs +++ b/tests/runtime-adapter/tests/vm_clone.rs @@ -117,6 +117,7 @@ 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::sharing::HostFsSharing; use runtime_adapter::staged::Staged; use common::gpt::{self, Contents, Layout, Partition, hexadecimal, small_layer}; @@ -1081,12 +1082,13 @@ fn refuses_early(template: &Template, early: Message) { scratch_journal: None, first_partition_bound: None, machine_id: MachineIdMode::Image, + sharing: HostFsSharing::default(), }; let plan = request.plan().unwrap(); let staged = Staged::read(&sandbox.store).unwrap().unwrap(); let key = store.read_key(&template.generation()).unwrap(); let clone = request - .clone_request(plan.clone(), &staged.bindings(&plan), key.memory()) + .clone_request(plan.clone(), None, &staged.bindings(&plan), key.memory()) .unwrap(); let now = HostTime::now().unwrap(); let mut acquired = store From 2fcbbaed7bad75e2224e876c1d6655e5ac0c88d9 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sun, 25 Oct 2026 21:02:18 -0700 Subject: [PATCH 4/5] runtime-adapter: start clones with exports, in VMs The VM tests start clones that share host directories with their roots, through `runtime-adapter-host start` and its shares, which NVX exports as one directory, bound to the template's dormant filesystem slot, and whose mapping table the restore sequence sends once the clone is ready. In vm_clone: - A clone over a scratch image shares one host directory: the directory itself, read-only, a read-write directory inside it, at its target there, a read-only file, and a read-write directory, with a file hidden. It starts with four mappings, and its activation record names the export, a directory, by the identity of its plan, with four mappings. On Linux, its VMM has one `--mount`, of the directory, read-write. The guest mounted the export once, read-write, nosuid and nodev, and the mappings below the root, in the table's order, each with its access, showing what the host holds but for the hidden file. An execution finds the same, and the writable mappings alone take its writes, which the host's directory holds, before and after the stop. - Four clones of one generation at once: one with a read-write export of its own and a generated machine ID, two over the same read-only export, and one without an export. Each record names its export by the identity of its plan, the same for the clones of one export, and the clone without one has none, no mapping, and on Linux no `--mount`, which leaves the slot dormant. Each export is mounted read-write, even where its mapping is read-only; the mount table lists the bind of the generated machine ID beside the mapping; and the helper of the `entries` probe, which writes the root's machine ID, finds that file read-only. - A start refuses two directories, or a file, before any VMM starts, with status 8 and the record of a failure of the request, at the plan, by the evidence of invalid mappings. - A clone whose second mapping puts a directory onto a file of the image ends at its Ready, before its sandbox starts, with status 8, the guest's report that the table failed after its first mapping, and the record of a failure of the start, by the evidence of the protocol: nothing quarantined, the lease released, and the host's directory unchanged. The harness reads a clone's activation record from its sandbox, and on Linux the arguments of its VMM. Part of #846 Part of #845 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 34e6ffe3-3ebf-42db-bfca-e0ecfade6873 --- tests/runtime-adapter/README.md | 8 +- tests/runtime-adapter/tests/vm_clone.rs | 642 +++++++++++++++++++++++- 2 files changed, 646 insertions(+), 4 deletions(-) diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index 96c76ee63..2ec10927e 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -2504,7 +2504,9 @@ 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 layers, empty but for one disk's regular file `etc`, at -partitions that differ from one disk to another: +partitions that differ from one disk to another. The host directories that +clones export lie in the template's directory, beside the template store, in a +directory that the starts authorize: | Test | Clones | Expects | | --- | --- | --- | @@ -2524,6 +2526,10 @@ partitions that differ from one disk to another: | `a_clone_whose_vm_exits_once_a_stop_found_it_fails_before_its_lease_is_released` | In RAM, then a `stop` that the step hook pauses at `found`, while the test terminates the clone's VMM | Status 8, `reattached=live` and `stopped=exited`, and the record of a failure of an unknown cause, at the run, by the evidence of the clone's lost status, without a status of the guest's, once the VM exited, which quarantines nothing; the lease released, with the records; then another clone that starts and stops | | `a_tombstone_leaves_the_running_clone_of_its_generation_to_its_lease` | Over a scratch image, then a tombstone of its generation, through NVX's store | A start that the tombstone refuses, without a lease; the running clone, which reattaches, probes, runs an execution, and stops, which releases its lease; and the generation's snapshot unchanged | | `the_lease_of_a_start_that_ended_before_its_record_is_released_through_its_template_store` | In RAM, in a sandbox that NVX's store leased a generation to, as a start that ended before it recorded the clone leaves it: first moved, with another sandbox at its lease's path, which holds a lease of its own, then none; then in place, without a record of a clone, then with the record of an earlier clone, of another template | For the moved sandbox, a `stop` and a `reattach` that refuse it, since the sandbox at its lease's path does not hold its lease, and leave that sandbox's lease; in place, a stop that finds the clone `ended` at `planned`, and keeps the lease, whose template store no record names; a stop with another template store, which the release refuses; a `reattach` or a `stop` with `--template` naming the lease's own, which releases it; and a start and a stop in the sandbox after each | +| `a_clone_maps_its_export_beneath_its_root_before_its_sandbox_starts` | Over a scratch image, sharing one host directory: the directory itself, read-only, a read-write directory inside it, at its target there, a read-only file, and a read-write directory, with a file hidden | Started, with four mappings, and a record that names the export, a directory, by the identity of its plan, with four mappings; on Linux, the VMM's one `--mount`, of the directory, read-write; the export mounted once, read-write, `nosuid`, and `nodev`, below a `hostfs` of mode 0700 that root owns; the mappings below the root, and nothing else, in the table's order, each with its access, `nosuid`, and `nodev`, showing what the host holds but for the hidden file, with the missing targets created empty in the upper; the same from an execution as user ID 0; and writes through the writable mappings alone, which the host's directory holds, before and after the stop | +| `clones_at_once_see_only_their_own_exports` | Four at once: one with a read-write export of its own and a generated machine ID, two over the same read-only export, over a scratch image and in RAM, and one without an export | Each with an export started with one mapping, and a record that names it by the identity of its plan, the same for the clones of one export, and on Linux, one `--mount`, read-write or read-only, as the mapping needs; each export mounted read-write, even where its mapping is read-only; below the root, the mapping, beside the bind of a generated machine ID; each clone's own directory, with its access; without an export, no export in the record, no mapping, no `--mount` on Linux, no export mounted, and nothing below the root; the `entries` probe, whose helper writes the root's machine ID, failing with `EROFS` where the generated one is bound; the host's directories with what the writable export took alone; then each stop releases its lease | +| `a_start_refuses_an_export_that_a_dormant_filesystem_slot_cannot_bind_before_any_vmm_starts` | Sharing two host directories, and sharing a file alone | 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 invalid mappings, which quarantines nothing; the lease released, and the host's directories unchanged | +| `a_clone_whose_mapping_fails_ends_before_its_sandbox_starts` | Over a scratch image, sharing a host directory whose second mapping puts a directory onto a file of the image | Status 8, a `Ready`, but no start, and the record of a failure of an unknown cause, at the start, at the clone's `Ready`, by the evidence of the protocol, once the VM exited with the guest's success, or NVX terminated it, with the guest's report that the table failed after its first mapping; nothing quarantined, and the lease released, with the records; the host's directory unchanged | A test that fails keeps its template's directory, with the template store and the platform's image, as long as it keeps the directory of a clone that leases diff --git a/tests/runtime-adapter/tests/vm_clone.rs b/tests/runtime-adapter/tests/vm_clone.rs index d75cc207d..d3e0cee85 100644 --- a/tests/runtime-adapter/tests/vm_clone.rs +++ b/tests/runtime-adapter/tests/vm_clone.rs @@ -43,6 +43,22 @@ //! - A clone whose generated machine ID the guest cannot bind, since its image holds a regular //! file `etc`, ends before it is ready, with the status of the workload's view, while a clone of //! the image that keeps the image's own machine ID, which needs no bind, starts. +//! - A clone maps its export beneath its root once it is ready, before its sandbox starts: one +//! host directory, the export's root, which NVX binds to the template's dormant filesystem slot, +//! and whose mappings, the directory's own, from an empty source, and those of paths inside it, +//! each keep their access, show what the host holds, but for a path that the start hides, and +//! take what the guest, and the clone's workload, write through the writable ones alone. The +//! clone's record names the export by the identity of its plan, with the number of its +//! mappings. +//! - Clones of one generation at once see only their own exports: each its own directory, or two +//! the same one, which their records name by the same identity, and a clone without an export +//! none, whose restore leaves the slot dormant. A clone mounts its export read-write, even where +//! every mapping is read-only, mount tables list the bind of a generated machine ID below the +//! root beside the mappings, and the bind holds even against the guest's root. +//! - A start refuses an export that the template's dormant filesystem slot cannot bind before any +//! VMM starts, as a failure of the request: one of two directories, and one of a file. +//! - A clone whose guest fails a mapping of its table ends before its sandbox starts, nothing of +//! it runs, and the host's directory stays as it was. //! - 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. @@ -82,7 +98,7 @@ mod common; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::ffi::{OsStr, OsString}; use std::fs::{self, File}; @@ -93,9 +109,13 @@ use std::path::{Path, PathBuf}; use std::thread; use std::time::{Duration, Instant, SystemTime}; -use nvx::activation::{MachineIdMode, ReadyActivation, StoreGeneration}; +use nvx::activation::{ + ActivationRecord, Configuration, ExportKind, MachineIdMode, ReadyActivation, StoreGeneration, +}; use nvx::exec::{Key, Limits, Timers, Window}; use nvx::host::artifacts::Artifacts; +#[cfg(target_os = "linux")] +use nvx::host::filesystem::GUEST_EXPORT; use nvx::host::platform::Transport; use nvx::host::process::{BootId, Process}; use nvx::host::store::{CallerFiles, LOG_NAME, SandboxStore}; @@ -171,6 +191,66 @@ fi echo "marker $(cat /marker 2>/dev/null || echo absent)" printf '%s' "$1" > /marker "#; +/// The directory of a template's sandbox that holds the host directories that the scenario's +/// clones export, the one host directory that their starts authorize. +const EXPORTS: &str = "exports"; +/// The directories of the host directory that a clone exports in full, with the +/// [`EXPORT_SHARES`]: `dir`, which holds the empty directory `inner`, and `out`. +const EXPORT_DIRECTORIES: [&str; 3] = ["dir", "dir/inner", "out"]; +/// The files of the host directory that a clone exports in full, each a path in it, with +/// components separated by `/`, and its contents. +const EXPORT_FILES: [(&str, &str); 4] = [ + ("dir/marker", "marker"), + ("dir/secret", "secret"), + ("file.txt", "file"), + ("out/marker", "marker"), +]; +/// The shares of a clone that exports a host directory in full, each a path in it, `.` for the +/// directory itself, and its target in the root, with `:ro` for a read-only share: the directory, +/// read-only, with the read-write directory `dir/inner` inside it, at the target that it has +/// there, the read-only file `file.txt`, and the read-write directory `out`. The plan exports the +/// directory alone, the export's root, whose mapping comes from an empty source, and the start +/// hides `dir/secret`. +const EXPORT_SHARES: [(&str, &str); 4] = [ + (".", "/srv/maps:ro"), + ("dir/inner", "/srv/maps/dir/inner"), + ("file.txt", "/srv/file.txt:ro"), + ("out", "/srv/out"), +]; +/// The `map` probe of the [`EXPORT_SHARES`]: each target, and the hidden file. +const MAP_EXPORT: &str = "map maps=srv/maps inner=srv/maps/dir/inner file=srv/file.txt \ + out=srv/out secret=srv/maps/dir/secret"; +/// The mappings of the [`EXPORT_SHARES`], in the order in which the guest applied them, which the +/// root's mount table keeps: by the depth of their targets, then by their targets. +const EXPORT_MOUNTS: &str = "/srv/file.txt,/srv/maps,/srv/out,/srv/maps/dir/inner"; +/// The program of the executions that show what a clone's workload finds of the +/// [`EXPORT_SHARES`], `/bin/sh -c EXPORTED sh`, which prints, a line each: the contents of +/// `/srv/maps/dir/marker` and of `/srv/file.txt`, `read PATH TEXT`, or `read PATH refused` for one +/// that cannot be read; whether the hidden `/srv/maps/dir/secret` could be read, `secret read`, +/// or not, `secret refused`; the names in `/srv/maps/dir`, each followed by a comma, `listing +/// NAMES`; and for each of `/srv/maps`, `/srv/maps/dir/inner`, and `/srv/out`, whether a write of +/// the new file `workload` in it succeeded, `write DIRECTORY written`, or not, `write DIRECTORY +/// refused`. +const EXPORTED: &str = r#"for path in /srv/maps/dir/marker /srv/file.txt; do + if text=$(cat "$path" 2>/dev/null); then + echo "read $path $text" + else + echo "read $path refused" + fi +done +if cat /srv/maps/dir/secret >/dev/null 2>&1; then echo "secret read"; else echo "secret refused"; fi +echo "listing $(ls /srv/maps/dir | tr '\n' ',')" +for directory in /srv/maps /srv/maps/dir/inner /srv/out; do + if { printf '%s' workload > "$directory/workload"; } 2>/dev/null; then + echo "write $directory written" + else + echo "write $directory refused" + fi +done +"#; +/// The `map` probe of the clones that share one host directory at `/srv/data`: the directory, and +/// its file `marker`. +const MAP_DATA: &str = "map data=srv/data marker=srv/data/marker"; /// 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 { @@ -363,6 +443,16 @@ impl Template { .store } + /// Returns the directory of the template's sandbox, which holds the template store, the + /// platform's directory, and the directory of the host directories that the scenario's clones + /// export, [`EXPORTS`]. + fn root(&self) -> &Path { + let sandbox = self.sandbox.as_ref(); + &sandbox + .expect("the sandbox lasts until the template drops") + .root + } + /// Returns the template store. fn store(&self) -> Store { Store::open(self.dir()).unwrap() @@ -581,6 +671,12 @@ impl Cloned { /// Checks that the start started the clone, as the [module documentation](self) describes, /// leasing the generation, and recorded its VM, which runs. fn assert_started(&self) { + self.assert_mapped(0); + } + + /// Checks that the start started the clone, as [`assert_started`](Self::assert_started) does, + /// once its guest applied `mapped` mappings of its export, none for a clone without one. + fn assert_mapped(&self, mapped: usize) { let started = &self.started; assert_eq!(started["generation"], GENERATION.to_string(), "{started:?}"); let lease = &started["lease"]; @@ -589,10 +685,11 @@ impl Cloned { "{started:?}" ); started["downtime"].parse::().unwrap(); + let mapped = mapped.to_string(); let facts = ["root", "build", "table", "mapped", "sandbox"].map(|key| &started[key]); assert_eq!( facts, - ["ready", GUEST_BUILD, "applied", "0", "started"], + ["ready", GUEST_BUILD, "applied", mapped.as_str(), "started"], "{started:?}" ); let record = self.sandbox.record().expect("the start recorded its VM"); @@ -714,6 +811,66 @@ impl Cloned { marker: value(marker, "marker"), } } + + /// Runs `script` with `/bin/sh -c` in the clone's root, as user ID 0, checks that it exited + /// with status 0, and wrote nothing to its standard error, and returns the lines that it + /// printed. + fn script(&mut self, script: &str) -> Vec { + let program = ["/bin/sh", "-c", script, "sh"]; + let ran = self.exec(&[&["--identity", "0:0", "--"][..], &program].concat()); + let facts = ["launch", "status", "stderr"].map(|key| &ran[&format!("exec.1.{key}")]); + assert_eq!(facts, ["started", "exit 0", ""], "{ran:?}"); + // The host program escapes each line feed, and each backslash, which none of the lines + // holds. + ran["exec.1.stdout"] + .strip_suffix("\\n") + .filter(|lines| !lines.contains("\\\\")) + .unwrap_or_else(|| panic!("{ran:?}")) + .split("\\n") + .map(str::to_owned) + .collect() + } + + /// Returns the configuration of the clone's activation record, which NVX keeps in the clone's + /// sandbox, in its canonical encoding, while the clone holds its lease. + fn configuration(&self) -> Configuration { + let store = SandboxStore::open(&self.sandbox.store).unwrap(); + let encoded = fs::read(store.clone_activation_path()).unwrap(); + let record = ActivationRecord::decode(&encoded).unwrap(); + record.configuration().clone() + } + + /// Checks the arguments with which NVX started the clone's VMM, on Linux, where the test, as + /// root, reads them: one `--mount`, which binds the directory of `export`, as the plan + /// resolved it, to the template's dormant filesystem slot, with the access `mode`, or none, + /// for a clone without an export, whose restore leaves the slot dormant. Elsewhere, NVX's own + /// tests of a clone's launch check the arguments, through a fake VMM. + fn assert_vmm_mount(&self, export: Option<(&Export, &str)>) { + #[cfg(target_os = "linux")] + { + let pid = &self.started["pid"]; + let command_line = fs::read(format!("/proc/{pid}/cmdline")).unwrap(); + let arguments = command_line + .split(|byte| *byte == 0) + .map(|argument| String::from_utf8_lossy(argument).into_owned()) + .collect::>(); + let mounts = arguments + .windows(2) + .filter(|pair| pair[0] == "--mount") + .map(|pair| pair[1].as_str()) + .collect::>(); + let expected = export + .map(|(export, mode)| { + let directory = fs::canonicalize(&export.root).unwrap(); + format!("{GUEST_EXPORT},{},{mode}", directory.display()) + }) + .into_iter() + .collect::>(); + assert_eq!(mounts, expected, "{arguments:?}"); + } + #[cfg(not(target_os = "linux"))] + let _ = export; + } } /// What an execution of [`VIEW`] found in a clone's root. @@ -731,6 +888,124 @@ struct View { marker: String, } +/// A host directory that a scenario's clones export, in the [`EXPORTS`] of the template's +/// sandbox, beside the template store, which neither holds the other. +struct Export { + /// The directory. + root: PathBuf, +} + +impl Export { + /// Writes the directory `name` in the [`EXPORTS`] of the sandbox of `template`, with `files`, + /// each a path in it, with components separated by `/`, and its contents, and the empty + /// `directories`, and lets anyone create entries in each of its directories, itself included, + /// so that only a mapping's access refuses what a clone's execution writes there. + /// + /// A Linux host shows the guest each host object's owner and mode, which the guest's kernel + /// checks: on an unprivileged host, the directories belong to the test's user, not to user + /// ID 0, as which an execution runs without the capabilities that override the check, so they + /// would refuse its writes. A Windows host shows the guest user ID 0 as each object's owner, so + /// there the directories keep their access. + fn write( + template: &Template, + name: &str, + files: &[(&str, &str)], + directories: &[&str], + ) -> Self { + let export = Self { + root: template.root().join(EXPORTS).join(name), + }; + fs::create_dir_all(&export.root).unwrap(); + for directory in directories { + fs::create_dir_all(export.path(directory)).unwrap(); + } + for (file, contents) in files { + let path = export.path(file); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mut pending = vec![export.root.clone()]; + while let Some(directory) = pending.pop() { + fs::set_permissions(&directory, fs::Permissions::from_mode(0o777)).unwrap(); + for entry in fs::read_dir(&directory).unwrap() { + let entry = entry.unwrap(); + if entry.file_type().unwrap().is_dir() { + pending.push(entry.path()); + } + } + } + } + export + } + + /// Returns the host path of `path`, a path in the directory, with components separated by + /// `/`, or `.` for the directory itself. + fn path(&self, path: &str) -> PathBuf { + path.split('/') + .filter(|name| *name != ".") + .fold(self.root.clone(), |host, name| host.join(name)) + } + + /// Returns the options of a start that shares each of `shares` with the clone's root, a path + /// in the directory, `.` for the directory itself, and its target, with `:ro` for a read-only + /// share, hides each of `denied`, paths in the directory, and authorizes the [`EXPORTS`]. + fn options(&self, shares: &[(&str, &str)], denied: &[&str]) -> Vec { + let mut options = Vec::new(); + for (path, target) in shares { + let share = format!("{}:{target}", self.path(path).display()); + options.extend(["--share".to_owned(), share]); + } + for path in denied { + options.extend(["--deny".to_owned(), self.path(path).display().to_string()]); + } + let exports = self.root.parent().unwrap(); + options.extend(["--share-root".to_owned(), exports.display().to_string()]); + options + } + + /// Returns what the directory holds, at any depth: each path in it, with components separated + /// by `/`, and a file's contents, or `None` for a directory. + fn tree(&self) -> BTreeMap> { + let mut found = BTreeMap::new(); + let mut pending = vec![(self.root.clone(), String::new())]; + while let Some((path, prefix)) = pending.pop() { + for entry in fs::read_dir(&path).unwrap() { + let entry = entry.unwrap(); + let name = format!("{prefix}{}", entry.file_name().to_str().unwrap()); + if entry.file_type().unwrap().is_dir() { + pending.push((entry.path(), format!("{name}/"))); + found.insert(name, None); + } else { + found.insert(name, Some(fs::read_to_string(entry.path()).unwrap())); + } + } + } + found + } +} + +/// Returns the tree of a directory that holds the directories `directories`, and `files`, each a +/// path in it, with components separated by `/`, and its contents, as [`Export::tree`] lists it. +fn tree_of(directories: &[&str], files: &[(&str, &str)]) -> BTreeMap> { + let mut tree = BTreeMap::new(); + for directory in directories { + tree.insert((*directory).to_owned(), None); + } + for (file, contents) in files { + tree.insert((*file).to_owned(), Some((*contents).to_owned())); + } + tree +} + +/// Returns the options of a mount, as the `map` probe gives them. +fn mount_options(options: &str) -> BTreeSet<&str> { + options.split(',').collect() +} + /// 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 { @@ -2209,3 +2484,364 @@ fn start_in(sandbox: &mut Sandbox, template: &Template, templates: &Path) -> Pri ]; sandbox.run("start", &options, 0) } + +#[test] +#[ignore = "boots VMs; see the module documentation for what they need"] +fn a_clone_maps_its_export_beneath_its_root_before_its_sandbox_starts() { + let template = Template::create("cl-export"); + let export = Export::write(&template, "full", &EXPORT_FILES, &EXPORT_DIRECTORIES); + let shares = export.options(&EXPORT_SHARES, &["dir/secret"]); + let options = shares.iter().map(String::as_str).collect::>(); + let start = StartOf { + upper: Upper::Scratch, + options: &options, + ..StartOf::default() + }; + let mut clone = template.start("cl-export-1", start, 0); + clone.assert_mapped(EXPORT_SHARES.len()); + + // The clone's record names the export, a directory, by the identity of its plan, and the + // number of its mappings, which the restore sequence sent once the clone was ready. NVX bound + // the directory to the template's dormant filesystem slot, read-write, since some of the + // mappings are. + let configuration = clone.configuration(); + let recorded = configuration.export.expect("the record names the export"); + assert_eq!(recorded.kind, ExportKind::Directory); + assert_eq!(configuration.mappings, EXPORT_SHARES.len()); + clone.assert_vmm_mount(Some((&export, "rw"))); + + // The export, mounted once, read-write, without set-user-ID programs or devices, below a + // directory of the runtime's that only root may enter, and the table's mappings below the + // root, and nothing else, in their order, as a launch's: each with its access, `nosuid`, and + // `nodev`, the guest writing the writable ones alone, the one inside a read-only mapping + // included, each showing what the host holds, but the file that the start hides. The guest + // created the missing targets in the upper, empty, but none inside another mapping. + let facts = clone.probe(&[MAP_EXPORT]); + let fact = |key: &str| facts[&format!("map.{key}")].as_str(); + assert_eq!( + (fact("export.count"), fact("export.type")), + ("1", "virtiofs") + ); + let options = mount_options(fact("export.options")); + assert!( + options.is_superset(&BTreeSet::from(["rw", "nosuid", "nodev"])), + "{options:?}" + ); + assert_eq!((fact("hostfs.mode"), fact("hostfs.owner")), ("0700", "0:0")); + assert_eq!(fact("mounts"), EXPORT_MOUNTS); + for (name, access, kind, write, upper) in [ + ("maps", "ro", "directory", "refused", "directory"), + ("inner", "rw", "directory", "allowed", "absent"), + ("file", "ro", "file 4", "refused", "file 0"), + ("out", "rw", "directory", "allowed", "directory"), + ] { + let options = mount_options(fact(&format!("{name}.options"))); + for flag in [access, "nosuid", "nodev"] { + assert!(options.contains(flag), "{name}: {options:?}"); + } + let found = ["kind", "write", "upper"].map(|key| fact(&format!("{name}.{key}"))); + assert_eq!(found, [kind, write, upper], "{name}"); + } + assert_eq!(fact("maps.entries"), "dir,file.txt,out"); + assert_eq!(fact("inner.entries"), ""); + assert_eq!(fact("file.read"), "file"); + assert_eq!(fact("out.entries"), "marker"); + let secret = ["options", "kind", "upper"].map(|key| fact(&format!("secret.{key}"))); + assert_eq!(secret, ["none", "denied", "absent"]); + assert_eq!(fact("latched"), "no"); + + // The clone's workload finds the same, as user ID 0: the files that the mappings show, but + // not the hidden one, and the writable mappings alone take what it writes. + assert_eq!( + clone.script(EXPORTED), + [ + "read /srv/maps/dir/marker marker", + "read /srv/file.txt file", + "secret refused", + "listing inner,marker,", + "write /srv/maps refused", + "write /srv/maps/dir/inner written", + "write /srv/out written", + ] + ); + + // The host's directory holds what the guest wrote through the writable mappings, and nothing + // else changed, before the stop, which tears the root down, and after it. + let mut expected = tree_of(&EXPORT_DIRECTORIES, &EXPORT_FILES); + for (file, contents) in [ + ("dir/inner/written", "runtime-adapter"), + ("dir/inner/workload", "workload"), + ("out/written", "runtime-adapter"), + ("out/workload", "workload"), + ] { + expected.insert(file.to_owned(), Some(contents.to_owned())); + } + assert_eq!(export.tree(), expected); + clone.stop(); + assert_eq!(export.tree(), expected); + assert!(template.leases().is_empty()); + assert_payload(&template, false); +} + +#[test] +#[ignore = "boots VMs; see the module documentation for what they need"] +fn clones_at_once_see_only_their_own_exports() { + let template = Template::create("cl-exports"); + let own = Export::write(&template, "own", &[("marker", "own")], &[]); + let common = Export::write(&template, "common", &[("marker", "common")], &[]); + let writable = own.options(&[(".", "/srv/data")], &[]); + let mut generated = writable.iter().map(String::as_str).collect::>(); + generated.extend(["--machine-id", "generated"]); + let read_only = common.options(&[(".", "/srv/data:ro")], &[]); + let read_only = read_only.iter().map(String::as_str).collect::>(); + // Four clones of one generation at once: one with a read-write export of its own and a + // generated machine ID, two over the same read-only export, over a scratch image and in RAM, + // and one without an export. + let starts = [ + ( + "cl-own", + StartOf { + options: &generated, + ..StartOf::default() + }, + ), + ( + "cl-common", + StartOf { + upper: Upper::Scratch, + options: &read_only, + ..StartOf::default() + }, + ), + ( + "cl-common-2", + StartOf { + options: &read_only, + ..StartOf::default() + }, + ), + ("cl-none", StartOf::default()), + ]; + let mut clones = starts.map(|(name, start)| template.start(name, start, 0)); + let [with_own, with_common, with_same, without] = &mut clones; + for clone in [&*with_own, &*with_common, &*with_same] { + clone.assert_mapped(1); + } + without.assert_started(); + + // Each record names its clone's export, a directory, by the identity of its plan, which the + // clones of the same export share, and one mapping; the clone without an export has none, and + // no mapping, and NVX restored it with no `--mount`, which leaves the template's filesystem + // slot dormant. NVX bound each directory to the slot with the access that its mapping needs. + let exported = |clone: &Cloned| { + let configuration = clone.configuration(); + let export = configuration + .export + .map(|export| (export.kind, export.plan)); + (export, configuration.mappings) + }; + let (own_export, own_mappings) = exported(with_own); + let (common_export, common_mappings) = exported(with_common); + let (own_export, common_export) = (own_export.unwrap(), common_export.unwrap()); + assert_eq!((own_export.0, own_mappings), (ExportKind::Directory, 1)); + assert_eq!( + (common_export.0, common_mappings), + (ExportKind::Directory, 1) + ); + assert_ne!(own_export.1, common_export.1); + assert_eq!(exported(with_same), (Some(common_export), 1)); + assert_eq!(exported(without), (None, 0)); + with_own.assert_vmm_mount(Some((&own, "rw"))); + with_common.assert_vmm_mount(Some((&common, "ro"))); + with_same.assert_vmm_mount(Some((&common, "ro"))); + without.assert_vmm_mount(None); + + // Each clone with an export mounted it read-write, even where every mapping of its table is + // read-only, as a cold launch's would not be, since OpenVMM keeps the export's access, and each + // mapping carries its own. Below the root, beside its mapping, the mount table shows the bind + // of a generated machine ID. Each shows its own directory, and takes what its access allows. + for (clone, marker, access, write, mounts) in [ + ( + &mut *with_own, + "own", + "rw", + "allowed", + "/etc/machine-id,/srv/data", + ), + (&mut *with_common, "common", "ro", "refused", "/srv/data"), + (&mut *with_same, "common", "ro", "refused", "/srv/data"), + ] { + let facts = clone.probe(&[MAP_DATA]); + let fact = |key: &str| facts[&format!("map.{key}")].as_str(); + assert_eq!(fact("export.count"), "1", "{facts:?}"); + let options = mount_options(fact("export.options")); + assert!( + options.is_superset(&BTreeSet::from(["rw", "nosuid", "nodev"])), + "{facts:?}" + ); + assert_eq!(fact("mounts"), mounts, "{facts:?}"); + assert!( + mount_options(fact("data.options")).contains(access), + "{facts:?}" + ); + let data = ["kind", "entries", "write"].map(|key| fact(&format!("data.{key}"))); + assert_eq!(data, ["directory", "marker", write], "{facts:?}"); + let size = format!("file {}", marker.len()); + let found = ["kind", "read"].map(|key| fact(&format!("marker.{key}"))); + assert_eq!(found, [size.as_str(), marker], "{facts:?}"); + } + // The clone without an export mounted none, and maps nothing. + let facts = without.probe(&[MAP_DATA]); + let fact = |key: &str| facts[&format!("map.{key}")].as_str(); + assert_eq!(fact("export.count"), "0", "{facts:?}"); + assert!(!facts.contains_key("map.export.type"), "{facts:?}"); + assert_eq!(fact("hostfs.mode"), "absent", "{facts:?}"); + assert_eq!(fact("mounts"), "", "{facts:?}"); + let found = ["data.options", "data.kind", "marker.kind"].map(fact); + assert_eq!(found, ["none", "absent", "absent"], "{facts:?}"); + + // The read-only bind of the generated machine ID holds even against the guest's root: the + // helper of the `entries` probe, which writes the root's own machine ID first, finds its file + // system read-only. + with_own + .sandbox + .run("probe", &[OsStr::new("entries")], FAILED); + let stderr = with_own.sandbox.stderr(); + assert!(stderr.contains("Read-only file system"), "{stderr}"); + + // The host's directories hold what the clone of the writable export wrote, and nothing else. + let written = [("marker", "own"), ("written", "runtime-adapter")]; + assert_eq!(own.tree(), tree_of(&[], &written)); + assert_eq!(common.tree(), tree_of(&[], &[("marker", "common")])); + for clone in &mut clones { + clone.stop(); + } + assert!(template.leases().is_empty()); + assert_payload(&template, false); +} + +#[test] +#[ignore = "boots VMs; see the module documentation for what they need"] +fn a_start_refuses_an_export_that_a_dormant_filesystem_slot_cannot_bind_before_any_vmm_starts() { + let template = Template::create("cl-unbound"); + let one = Export::write(&template, "one", &[("marker", "one")], &[]); + let two = Export::write(&template, "two", &[("marker", "two")], &[]); + // Two directories, neither inside the other, which the plan exports as two children of an + // aggregate export, and a file alone, which the plan exports as its one child. + let mut several = one.options(&[(".", "/srv/one")], &[]); + several.extend([ + "--share".to_owned(), + format!("{}:/srv/two", two.root.display()), + ]); + let file = one.options(&[("marker", "/srv/marker:ro")], &[]); + let slot = "the export of a clone binds one directory to its template's dormant filesystem \ + slot, but its export plan exports"; + for (name, options, problem) in [ + ( + "cl-several", + several, + format!("{slot} 2 directories and files"), + ), + ("cl-file", file, format!("{slot} the file ")), + ] { + let options = options.iter().map(String::as_str).collect::>(); + let start = StartOf { + options: &options, + ..StartOf::default() + }; + let refused = template.start(name, start, CLONE_FAILED); + refused.assert_refused(&problem); + let printed = &refused.started; + assert_eq!( + failure(printed), + [ + "request", + "invalid-mappings", + "plan", + "planned", + "none", + "no-vm" + ], + "{printed:?}" + ); + assert_eq!(printed["quarantined"], "no", "{printed:?}"); + } + assert!(template.leases().is_empty()); + assert_eq!(one.tree(), tree_of(&[], &[("marker", "one")])); + assert_eq!(two.tree(), tree_of(&[], &[("marker", "two")])); + assert_payload(&template, false); +} + +#[test] +#[ignore = "boots VMs; see the module documentation for what they need"] +fn a_clone_whose_mapping_fails_ends_before_its_sandbox_starts() { + let template = Template::create("cl-mapfail"); + let export = Export::write(&template, "data", &[("out/marker", "marker")], &["out"]); + // The table's second mapping, after the directory's own, puts the directory `out` onto + // `/usr/lib/os-release`, a file of NVX's Ubuntu image, which the guest refuses once it + // applied the first. + let shares = export.options( + &[(".", "/srv/maps:ro"), ("out", "/usr/lib/os-release")], + &[], + ); + let options = shares.iter().map(String::as_str).collect::>(); + let start = StartOf { + upper: Upper::Scratch, + options: &options, + ..StartOf::default() + }; + let failed = template.start("cl-mapfail-1", start, CLONE_FAILED); + let printed = &failed.started; + + // The clone was ready, but the restore sequence sent no start once its guest failed the + // table: the failure is the start's, at the clone's Ready, by the evidence of the protocol, + // since the guest's refusal does not tell the request's mappings from a failure of its own. + // The clone order asked the guest to stop, which tears the root down, and NVX then ended the + // launch at once, which found the VM exited, with the guest's success, or terminated it, + // whether or not OpenVMM had reported the guest's success yet. + assert!(printed.contains_key("pid"), "{printed:?}"); + assert_eq!( + (printed["root"].as_str(), printed["build"].as_str()), + ("ready", GUEST_BUILD) + ); + for key in ["table", "mapped", "sandbox"] { + assert!(!printed.contains_key(key), "{printed:?}"); + } + let [outcome, evidence, stage, progress, status, teardown] = failure(printed); + assert_eq!( + [outcome, evidence, stage, progress], + ["unknown", "protocol", "start", "ready"], + "{printed:?}" + ); + assert!( + matches!( + (status, teardown), + ("0", "exited" | "terminated") | ("none", "terminated") + ), + "{printed:?}" + ); + let stderr = failed.sandbox.stderr(); + // The guest applied the directory's own mapping, the table's first, and refused the second: + // the guest's own report, which no other failure of the table prints. + assert!( + stderr.contains( + "the clone did not apply its mapping table: the mapping table failed after 1 \ + mapping: " + ), + "{stderr}" + ); + // Nothing of the clone runs: the failure quarantined nothing, and the start released the + // lease, with the records of the clone's launch. + assert_eq!(printed["quarantined"], "no", "{printed:?}"); + assert_eq!(printed["released"], printed["lease"], "{printed:?}"); + assert!(failed.sandbox.record().is_none()); + let store = SandboxStore::open(&failed.sandbox.store).unwrap(); + assert!(CloneRecords::read(&store).unwrap().is_none()); + assert!(template.leases().is_empty()); + // The guest wrote nothing to the host's directory. + assert_eq!( + export.tree(), + tree_of(&["out"], &[("out/marker", "marker")]) + ); + assert_payload(&template, false); +} From 551ebdad0142f4459a3f102242ceced499be5f3a Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sun, 25 Oct 2026 21:49:10 -0700 Subject: [PATCH 5/5] nvx-rs: resolve the template store's path in a clone's export check ClonePlan::new refuses an export that overlaps the template store, whose leased payloads nothing may change. It compared the paths that the export's plan resolved with the store's directory as the store names it, so another spelling of the store's path hid an overlap: a link; on Windows, another letter case, a short name, or the verbatim prefix; and on Unix, a `..` component, which an absolute path keeps. The plan now resolves the store's directory with filesystem::canonicalize, as filesystem::plan resolves the export's paths, before it compares them. The host still reaches the store, and OpenVMM the snapshots in it, by the path that the store names, so the plan also resolves the directories in which the host looks up the names of that path, links followed and `..` included, and refuses an export that lets the guest write in one of them: the guest could otherwise replace a link of the path, and turn the host's later operations on the store elsewhere, or make a directory of the path unsearchable, and keep the host from the store. It does both only for a clone with an export, so a clone without one reads nothing more. A store whose path cannot be resolved fails the plan as the host's failure. The plan also refuses an export whose directory is, or holds, the directory of the clone's sandbox, in which the host writes the clone's records by their paths: the guest could otherwise change them, or replace the directory with a link and turn the host's writes elsewhere, into a leased snapshot, say. The lease names the sandbox by its path with its links resolved, so the directories through which that path leads are the ones above it, which only such an export could let the guest change. A directory inside the sandbox's, among the caller's files, may still be exported. These checks compare paths, so an alias of the store that an administrator configures, such as a bind mount of it, is outside them, as the directories above the store are, and they cannot see what changes after them: OpenVMM resolves an export's path again at each launch and restore. The documentation of the export, in ClonePlan's and in the README, now says that whoever names an export must not name such an alias, nor a directory that holds one, nor one inside a directory that a writable export of another sandbox or clone holds: the directories above an export's directory must be ones that no guest may change, as those above the clone's block devices must. The test of the overlap names its store in another spelling of its directory: through `..` on Unix, and with the verbatim prefix on Windows. Another names it through two links, one to the other's directory: it refuses a writable export of the directory of either link, but not a read-only one, nor one whose writes the plan limits to a directory beside the link. A third names it through a directory and its `..`, which Unix looks up in that directory: Unix refuses a writable export of the directory, and Windows, which resolves `..` from the path's text, accepts it. A fourth refuses an export of the sandbox's directory, and of the directory that holds it, but not of a directory inside the sandbox's. Part of #846 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 34e6ffe3-3ebf-42db-bfca-e0ecfade6873 --- nvx-rs/README.md | 16 +- nvx-rs/src/host/template/clone.rs | 113 +++++++++++- nvx-rs/src/host/template/clone/export.rs | 80 +++++++- nvx-rs/src/host/template/clone/tests.rs | 222 +++++++++++++++++++++-- 4 files changed, 399 insertions(+), 32 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index f2e66b261..6e81ee210 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -660,8 +660,20 @@ reads, or for an upper in RAM a fresh sparse file, `clone-scratch.raw` in the sandbox's directory, which the release removes; its export, if it has one, an `ExportPlan` that `filesystem::plan` made for the container root, which must export exactly one directory, since the template's dormant filesystem slot binds -one directory and no file, and which must not overlap the template store, by the -paths that the plan resolved; and a fresh capability. +one directory and no file, and which must neither overlap the template store, by +the paths that the plan and the store's directory resolve to, nor let the guest +write in a directory through which the store's path leads, links included, since +the host reaches the store, and OpenVMM its snapshots, by that path, nor be or +hold the directory of the clone's sandbox, in which the host writes the clone's +records, checks of paths that leave two rules to whoever names the export, as +they leave the directories above the store: the export must not reach the store +through an alias of it that an administrator configures, such as a bind mount or +a mount point of the store, or of a directory above or inside it, a loopback +share, or a hard link of a payload, nor lie inside a directory that a writable +export of another sandbox or clone holds, since OpenVMM resolves an export's +path again at each launch and restore, so that the directories above the +export's directory must be ones that no guest may change, as those above a +clone's block devices must; 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, diff --git a/nvx-rs/src/host/template/clone.rs b/nvx-rs/src/host/template/clone.rs index 89d3f03bc..beea42a7b 100644 --- a/nvx-rs/src/host/template/clone.rs +++ b/nvx-rs/src/host/template/clone.rs @@ -26,10 +26,11 @@ pub use self::restore::{ RESTORE_TO_READY_TIMEOUT, RestoreDeadlines, Started, }; +use std::collections::BTreeSet; use std::fmt; use std::fs::{self, File}; use std::io; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::time::Instant; use self::export::DirectoryExport; @@ -62,6 +63,10 @@ use crate::protocol::{CAPABILITY_LEN, HostMap}; /// Bytes in a mebibyte, the unit in which messages give the size of memory blocks. const MIB: u64 = 1024 * 1024; +/// The most links that [`path_directories`] follows in a path, more than a Linux host, 40, or a +/// Windows host, 63, follows in one. +const MAX_PATH_LINKS: usize = 64; + /// How messages name the file of the `scratch` block device of a clone whose upper is in RAM. const SPARSE_WHAT: &str = "scratch device of the clone"; @@ -195,10 +200,26 @@ pub enum CloneUpper { /// directory: a template's filesystem slot is dormant, and a restore binds one directory to it, /// which is then the export's root, in which the sources of the plan's binds name their paths /// without the child's index, as the mapping table, [`table`](Self::table), sends them. The -/// directory may neither lie inside the template store's directory nor hold it, by the path -/// that the plan resolved, so that no guest reaches the payload of a generation through it. The -/// launch checks the plan's host objects again, with [`ExportPlan::verify`], before it restores -/// the clone. A request without an export leaves the slot dormant. +/// directory may neither lie inside the template store's directory nor hold it, by the paths +/// that the plan and the store's directory resolve to, so that no guest reaches the payload of +/// a generation through it, nor let the guest write in a directory through which the store's +/// path leads, links and `..` included, since the host reaches the store, and OpenVMM its +/// snapshots, by that path. Nor may it be, or hold, the directory of the clone's sandbox, which +/// the lease names by its resolved path, and in which the host writes the clone's records, +/// which the guest could otherwise change, or turn elsewhere, into a leased snapshot, say, +/// though a directory inside the sandbox's, among the caller's files, may be exported. These +/// checks compare paths, so an alias of the store that an administrator configures is outside +/// them, as the directories above the store are: a bind mount or a mount point of the store, +/// or of a directory above or inside it, a loopback share, or a hard link of a payload. +/// Whoever names an export must not name such an alias, nor a directory that holds one. The +/// launch checks the plan's host objects again, with [`ExportPlan::verify`], before it +/// restores the clone, but OpenVMM resolves an export's path again at each launch and restore, +/// so the directories above the export's directory must be ones that no guest may change, as +/// those above the clone's block devices must, which +/// [Block devices by path](Store#block-devices-by-path) describes: whoever names an export +/// must not name one inside a directory that a writable export of another sandbox or clone +/// holds, whose guest could replace the directory with a link, and turn the export elsewhere, +/// to a leased payload, say. A request without an export leaves the slot dormant. /// - 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, export, and settings, the template's network, and the resources that the clone must @@ -353,9 +374,13 @@ impl ClonePlan { /// that acquired the lease, before the launch that it reserves began, /// [`Evidence::HostFailure`]; /// - with [`ErrorKind::Unsupported`] for an export whose directory, by the path that its plan - /// resolved, lies inside the template store's directory, [`Store::dir`], or holds it, which - /// the plan compares without reading anything, and so without resolving the store's own - /// path, [`Evidence::InvalidMappings`]; + /// resolved, lies inside the template store's directory, [`Store::dir`], by the path that + /// it resolves to, or holds it, or is or holds the directory of the clone's sandbox, + /// [`Lease::sandbox`], or that lets the guest write in a directory through which the store's + /// path leads, as the host resolves it, links and `..` included, which the guest could then + /// turn elsewhere, or keep the host from following, [`Evidence::InvalidMappings`], + /// and with [`ErrorKind::Io`] if the store's directory, or a directory through which its + /// path leads, cannot be resolved, the host's evidence; /// - with [`ErrorKind::NotFound`] if the store no longer indexes the leased generation, and as /// [`Store::index`] fails, the host's evidence; with [`ErrorKind::Io`] if it indexes it with /// another key than the lease names, [`Evidence::MalformedGeneration`]; and as @@ -470,8 +495,24 @@ impl ClonePlan { let Acquired { lease, locked } = acquired; check_lease(store, lease, locked).attribute(Evidence::HostFailure)?; if let Some(export) = &export { + // The plan resolved the export's paths, so the store's path is resolved as well, so + // that no other spelling of it hides an overlap, and so are the directories through + // which it leads, in which the guest must not write. The lease names the sandbox's + // directory, in which the host writes the clone's records, by its resolved path. + let dir = store.dir(); + let unresolved = |error: io::Error| { + Error::io(format!( + "cannot resolve the template store {}", + dir.display() + )) + .with_source(error) + }; + let resolved = filesystem::canonicalize(dir) + .map_err(unresolved) + .on_host()?; + let through = path_directories(dir).map_err(unresolved).on_host()?; export - .check_outside(store.dir()) + .check_outside(dir, &resolved, &through, lease.sandbox()) .attribute(Evidence::InvalidMappings)?; } let generation = store @@ -763,6 +804,60 @@ fn generation_fault(error: Error) -> Fault { Fault::new(Evidence::MalformedGeneration, error) } +/// Returns the directories through which `path` leads, as the host resolves it, each resolved as +/// [`filesystem::canonicalize`] resolves the paths of an export plan: for each name of the path, +/// `..` included, from the current directory's own for a relative path, the directory in which +/// the host looks it up, which the path before the name resolves to, and, for a name that is a +/// link, the directories through which the link's target leads from there, through at most +/// [`MAX_PATH_LINKS`] links in all. +/// +/// The host reaches the template store, and OpenVMM the snapshots in it, by the path that the +/// store names, so a guest that could write in one of these directories could replace a link or +/// a directory of the path, and turn the host's later operations on the store elsewhere, or +/// make the directory unsearchable, and keep the host from the store. +fn path_directories(path: &Path) -> io::Result> { + let mut directories = BTreeSet::new(); + let mut pending = vec![(PathBuf::new(), std::path::absolute(path)?)]; + let mut links = 0; + while let Some((start, rest)) = pending.pop() { + let mut prefix = start; + for component in rest.components() { + match component { + Component::Normal(_) => {} + Component::CurDir | Component::ParentDir => { + // Unix looks `.` and `..` up in the directory before them, as it does a name, + // so a guest that could change that directory could keep the host from + // searching it. Windows resolves them from a path's text, as + // `std::path::absolute` did. + directories.insert(filesystem::canonicalize(&prefix)?); + prefix.push(component); + continue; + } + Component::RootDir | Component::Prefix(_) => { + // The root and a prefix name no entry. + prefix.push(component); + continue; + } + } + let parent = filesystem::canonicalize(&prefix)?; + prefix.push(component); + if fs::symlink_metadata(&prefix)?.file_type().is_symlink() { + links += 1; + if links > MAX_PATH_LINKS { + return Err(io::Error::other(format!( + "{} leads through more than {MAX_PATH_LINKS} links", + path.display() + ))); + } + // A link's target that is absolute replaces the directory that it starts from. + pending.push((parent.clone(), fs::read_link(&prefix)?)); + } + directories.insert(parent); + } + } + Ok(directories.into_iter().collect()) +} + /// Refuses the path of the file of a clone's block device, which messages call `what`, that /// OpenVMM would not open as the plan checks it: a relative one, which it would resolve against the /// directory in which it runs, one with a comma or a semicolon, which its block device options diff --git a/nvx-rs/src/host/template/clone/export.rs b/nvx-rs/src/host/template/clone/export.rs index d0fc8eca9..47907124b 100644 --- a/nvx-rs/src/host/template/clone/export.rs +++ b/nvx-rs/src/host/template/clone/export.rs @@ -10,7 +10,7 @@ //! directory is then the export's root, which the clone's guest mounts, and the sources of the //! plan's binds name paths in it without the child's index, which only an aggregate's root lists. -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::error::{Error, Result}; use crate::host::filesystem::{ExportPlan, GUEST_EXPORT, Owner, ReservedTargets}; @@ -57,22 +57,82 @@ impl DirectoryExport { } /// Refuses the export if its directory lies inside the directory of a template store, - /// `store`, or holds it: the guest could then reach the payload of a generation that clones + /// `resolved`, or holds it: the guest could then reach the payload of a generation that clones /// lease, and write it through a writable export, though nothing may change a leased payload - /// or its snapshot. + /// or its snapshot. Refuses it as well if it lets the guest write in one of `through`, the + /// directories through which the store's path, `store`, leads: the host reaches the store, + /// and OpenVMM the snapshots in it, by that path, so the guest could replace a link or a + /// directory of the path, and turn the path elsewhere, or make a directory of it + /// unsearchable, and keep the host from following it. /// - /// The check reads nothing: it compares the directory's path, as the plan resolved it, with - /// the store's, [`Store::dir`](crate::host::template::Store::dir), as the store names it, - /// which the check does not resolve, so it does not find an overlap that only the store's - /// resolved path shows, through a link, or on Windows another letter case or a short name. - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for such an export. - pub(super) fn check_outside(&self, store: &Path) -> Result<()> { + /// Refuses it too if its directory is, or holds, the directory of the clone's sandbox, + /// `sandbox`, which the lease names by its path with its links resolved: the host writes the + /// clone's records there, by their paths, so the guest could otherwise change them, or + /// replace the directory, or one above it, with a link, and turn the host's writes + /// elsewhere, into a leased snapshot, say. The directories above it are the only ones through + /// which that path leads, and only an export that holds it could let the guest change one. A + /// directory inside the sandbox's, among the caller's files, holds none of the host's records. + /// + /// The check reads nothing: it compares the directory's path, and the paths in which the + /// guest may write, as the plan resolved them, with `resolved`, `through`, and `sandbox`, + /// which the caller and the lease resolved as [`filesystem::canonicalize`] resolves the plan's + /// paths, so that no link, and on Windows no other letter case, short name, or verbatim + /// prefix, hides an overlap. Fails with + /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for such an export. + /// + /// Since the check compares the paths that the export and the store resolve to, an alias of + /// the store that an administrator configures is outside it, as the directories above the + /// store are: a bind mount or a mount point of the store, or of a directory above or inside + /// it, a loopback share, or a hard link of a payload. Whoever names an export must not name + /// such an alias, nor a directory that holds one. Nor does the check see what changes after + /// it: OpenVMM resolves an export's path again at each launch and restore, so the directories + /// above the export's directory must be ones that no guest may change, as those above the + /// clone's block devices must, which [Block devices by + /// path](crate::host::template::Store#block-devices-by-path) describes: whoever names an + /// export must not name one inside a directory that a writable export of another sandbox or + /// clone holds. + /// + /// [`filesystem::canonicalize`]: crate::host::filesystem::canonicalize + pub(super) fn check_outside( + &self, + store: &Path, + resolved: &Path, + through: &[PathBuf], + sandbox: &Path, + ) -> Result<()> { for child in &self.plan.children { - if child.root.starts_with(store) || store.starts_with(&child.root) { + if child.root.starts_with(resolved) || resolved.starts_with(&child.root) { return Err(Error::unsupported(format!( "the export of a clone cannot share {}, which overlaps the template store {}, \ whose leased payloads nothing may change", child.root.display(), + resolved.display() + ))); + } + if sandbox.starts_with(&child.root) { + return Err(Error::unsupported(format!( + "the export of a clone cannot share {}, which holds the directory of the \ + clone's sandbox {}, in which the host writes the clone's records", + child.root.display(), + sandbox.display() + ))); + } + // The guest may write anywhere in a writable directory, unless the plan limits its + // writes to some of the directory's paths. + let written: &[PathBuf] = match (child.writable, child.write.is_empty()) { + (false, _) => &[], + (true, true) => std::slice::from_ref(&child.root), + (true, false) => child.write.as_slice(), + }; + let directory = through + .iter() + .find(|directory| written.iter().any(|path| directory.starts_with(path))); + if let Some(directory) = directory { + return Err(Error::unsupported(format!( + "the export of a clone cannot let its guest write in {}, through which the \ + path of the template store {} leads, since the guest could then turn that \ + path elsewhere, or keep the host from following it", + directory.display(), store.display() ))); } diff --git a/nvx-rs/src/host/template/clone/tests.rs b/nvx-rs/src/host/template/clone/tests.rs index 35b2f2610..676bf5306 100644 --- a/nvx-rs/src/host/template/clone/tests.rs +++ b/nvx-rs/src/host/template/clone/tests.rs @@ -296,6 +296,23 @@ fn export_plan( filesystem::plan(&request).unwrap().unwrap() } +/// Returns another spelling of the existing directory `directory`, which names it, but which +/// differs from the path that resolves it: through a directory in it and its `..` on Unix, where +/// an absolute path keeps its `..` components, and with the verbatim prefix on Windows. +fn alias(directory: &Path) -> PathBuf { + #[cfg(windows)] + { + let resolved = filesystem::canonicalize(directory).unwrap(); + PathBuf::from(format!(r"\\?\{}", resolved.display())) + } + #[cfg(not(windows))] + { + let inner = directory.join("alias"); + fs::create_dir(&inner).unwrap(); + inner.join("..") + } +} + /// Returns the mapping table of these tests' exports, [`Fixture::data_export`]: the exported /// directory itself, the export's root, which an empty source names, and the directory `work` in /// it, by its path there, without the index of a child. @@ -595,6 +612,29 @@ fn created_link(linked: io::Result<()>) -> bool { } } +/// Links `link` to the directory `target`: a symbolic link on Unix, and a junction on Windows, +/// which any user may create. +fn link_dir(target: &Path, link: &Path) { + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).unwrap(); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + use std::process::{Command, Stdio}; + + let status = Command::new("cmd") + .raw_arg(format!( + "/C mklink /J \"{}\" \"{}\"", + link.display(), + target.display() + )) + .stdout(Stdio::null()) + .status() + .unwrap(); + assert!(status.success(), "cannot link {}", link.display()); + } +} + #[test] fn a_clone_in_ram_restores_its_lease_with_its_image_and_a_sparse_scratch_device() { let fixture = Fixture::new(); @@ -2634,20 +2674,14 @@ fn exports_that_a_dormant_filesystem_slot_does_not_bind_are_refused_before_anyth #[test] fn exports_that_overlap_the_template_store_are_refused_before_anything_is_opened() { - // The plan compares the paths that the export's plan resolved with the store's, as the store - // names it, without reading anything: this store's path is resolved. - let fixture = Fixture::in_store( - |directory| { - filesystem::canonicalize(directory) - .unwrap() - .join("templates") - }, - |_, _, _| {}, - ); + // The plan compares the paths that the export's plan resolved with the store's, resolved as + // well: this store's path names its directory in another spelling than its resolved one. + let fixture = Fixture::in_store(|directory| alias(directory).join("templates"), |_, _, _| {}); let (image, binding) = fixture.image("distro.erofs", IMAGE_LEN); let mut acquired = fixture.acquire("sandbox"); let request = tmpfs_request(&image, &binding); - let store = fixture.store.dir().to_owned(); + let store = filesystem::canonicalize(fixture.store.dir()).unwrap(); + assert_ne!(store, fixture.store.dir()); let payload = fixture.store.payload_dir(fixture.generation.generation()); // The store's directory, the payload of a generation inside it, and a directory that holds // the store, which a guest could reach the payload through, and write it if writable. @@ -2679,6 +2713,172 @@ fn exports_that_overlap_the_template_store_are_refused_before_anything_is_opened drop(fixture.plan(&mut acquired, &request, NO_CONTENT).unwrap()); } +#[test] +fn exports_that_let_the_guest_turn_the_template_store_s_path_are_refused() { + // The store's path leads through two links: `share/link`, to `hop/next`, which links to + // `private`, which holds the store. Neither directory of a link holds any part of the store, + // but a guest that could write in either could replace its link, and turn the path, by which + // the host reaches the store, and OpenVMM the snapshots in it, elsewhere. + let fixture = Fixture::in_store( + |directory| { + for name in ["share", "hop", "private"] { + fs::create_dir(directory.join(name)).unwrap(); + } + fs::create_dir(directory.join("share").join("work")).unwrap(); + let next = directory.join("hop").join("next"); + link_dir(&directory.join("private"), &next); + let link = directory.join("share").join("link"); + link_dir(&next, &link); + link.join("templates") + }, + |_, _, _| {}, + ); + let (image, binding) = fixture.image("distro.erofs", IMAGE_LEN); + let mut acquired = fixture.acquire("sandbox"); + let request = tmpfs_request(&image, &binding); + let resolved = |name: &str| filesystem::canonicalize(&fixture.directory.path().join(name)); + let (share, hop) = (resolved("share").unwrap(), resolved("hop").unwrap()); + // The store's path resolves outside both directories, so no export of either overlaps it. + let store = filesystem::canonicalize(fixture.store.dir()).unwrap(); + assert_eq!(store, resolved("private").unwrap().join("templates")); + // A writable export of either directory. + for dir in [&share, &hop] { + let export = export_plan(&[(dir, "/data", false)], &[], &[]); + let request = with_export(request.clone(), &export); + let plan = fixture.plan(&mut acquired, &request, NO_CONTENT); + assert_eq!( + refused(plan, Evidence::InvalidMappings), + ( + ErrorKind::Unsupported, + format!( + "the export of a clone cannot let its guest write in {}, through which the \ + path of the template store {} leads, since the guest could then turn that \ + path elsewhere, or keep the host from following it", + dir.display(), + fixture.store.dir().display() + ) + ) + ); + assert_untouched(&acquired); + } + // A read-only export of the directory of a link, and one in which the guest writes only a + // directory beside the link, leave the link as it is. + let work = share.join("work"); + for mappings in [ + &[(share.as_path(), "/data", true)][..], + &[ + (share.as_path(), "/data", true), + (work.as_path(), "/work", false), + ], + ] { + let export = export_plan(mappings, &[], &[]); + let request = with_export(request.clone(), &export); + drop(fixture.plan(&mut acquired, &request, NO_CONTENT).unwrap()); + } +} + +#[test] +fn exports_that_let_the_guest_block_the_template_store_s_path_are_refused() { + // The store's path leads through `traverse` and its `..`, which Unix looks up in `traverse`: + // a guest that could write there could make it unsearchable, and keep the host from the + // store. Windows resolves `..` from the path's text, and never looks in `traverse`. + let fixture = Fixture::in_store( + |directory| { + for name in ["traverse", "private"] { + fs::create_dir(directory.join(name)).unwrap(); + } + fs::create_dir(directory.join("traverse").join("work")).unwrap(); + directory + .join("traverse") + .join("..") + .join("private") + .join("templates") + }, + |_, _, _| {}, + ); + let (image, binding) = fixture.image("distro.erofs", IMAGE_LEN); + let mut acquired = fixture.acquire("sandbox"); + let request = tmpfs_request(&image, &binding); + let traverse = filesystem::canonicalize(&fixture.directory.path().join("traverse")).unwrap(); + // A writable export of the directory. + let export = export_plan(&[(&traverse, "/data", false)], &[], &[]); + let writable = with_export(request.clone(), &export); + let plan = fixture.plan(&mut acquired, &writable, NO_CONTENT); + if cfg!(windows) { + drop(plan.unwrap()); + } else { + assert_eq!( + refused(plan, Evidence::InvalidMappings), + ( + ErrorKind::Unsupported, + format!( + "the export of a clone cannot let its guest write in {}, through which the \ + path of the template store {} leads, since the guest could then turn that \ + path elsewhere, or keep the host from following it", + traverse.display(), + fixture.store.dir().display() + ) + ) + ); + assert_untouched(&acquired); + } + // A read-only export of the directory, and one in which the guest writes only a directory + // in it, leave the directory as it is. + let work = traverse.join("work"); + for mappings in [ + &[(traverse.as_path(), "/data", true)][..], + &[ + (traverse.as_path(), "/data", true), + (work.as_path(), "/work", false), + ], + ] { + let export = export_plan(mappings, &[], &[]); + let request = with_export(request.clone(), &export); + drop(fixture.plan(&mut acquired, &request, NO_CONTENT).unwrap()); + } +} + +#[test] +fn exports_that_hold_the_sandbox_s_directory_are_refused() { + // The host writes the clone's records in the sandbox's directory, by their paths, so a guest + // that could reach the directory could change them, or replace it with a link, and turn the + // host's writes elsewhere, into a leased snapshot, say. + let fixture = Fixture::new(); + let (image, binding) = fixture.image("distro.erofs", IMAGE_LEN); + let mut acquired = fixture.acquire("sandbox"); + let request = tmpfs_request(&image, &binding); + let sandbox = acquired.lease.sandbox().to_owned(); + // The sandbox's directory, writable, and the directory that holds it, read-only. + for (dir, read_only) in [ + (sandbox.clone(), false), + (sandbox.parent().unwrap().to_owned(), true), + ] { + let export = export_plan(&[(&dir, "/data", read_only)], &[], &[]); + let request = with_export(request.clone(), &export); + let plan = fixture.plan(&mut acquired, &request, NO_CONTENT); + assert_eq!( + refused(plan, Evidence::InvalidMappings), + ( + ErrorKind::Unsupported, + format!( + "the export of a clone cannot share {}, which holds the directory of the \ + clone's sandbox {}, in which the host writes the clone's records", + dir.display(), + sandbox.display() + ) + ) + ); + assert_untouched(&acquired); + } + // A directory inside the sandbox's, among the caller's files, holds none of the host's + // records. + let shared = sandbox.join("shared"); + fs::create_dir(&shared).unwrap(); + let export = export_plan(&[(&shared, "/data", false)], &[], &[]); + let request = with_export(request, &export); + drop(fixture.plan(&mut acquired, &request, NO_CONTENT).unwrap()); +} + #[test] fn the_export_of_a_clone_binds_its_directory_with_its_policy_and_owner() { let fixture = Fixture::new();