From 78441169f19afee721f0da1f12d7d391337796ea Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Wed, 28 Oct 2026 18:28:07 -0700 Subject: [PATCH 1/3] runtime-adapter: show hidden paths again, and pause a start once checked The aggregate exports of clones (#857) hide paths and show paths inside them again. The shares of a launch, a start, and a run take --allow beside --deny, which Shares::plan passes to NVX's planner as the request's allowed paths, so that the adapter's VM scenarios can export them. A start takes RUNTIME_ADAPTER_STEP_HOOK at checked alone: once NVX's ClonePlan checked the clone against the leased generation, and holds the files that its checks opened, before the start records the clone, and before the clone's launch checks its export again, so that a VM scenario can change a shared object there. A hook at another step is a usage error of a start. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: afd23653-4313-4e60-ab6b-350dffc1890b --- tests/runtime-adapter/README.md | 69 +++++++----- .../src/bin/runtime-adapter-host.rs | 100 ++++++++++++------ tests/runtime-adapter/src/hook.rs | 10 +- tests/runtime-adapter/src/layered.rs | 13 +-- tests/runtime-adapter/src/sharing.rs | 38 +++++-- tests/runtime-adapter/tests/clone_start.rs | 18 +++- tests/runtime-adapter/tests/layered_launch.rs | 35 +++++- tests/runtime-adapter/tests/step_hook.rs | 67 +++++++++++- 8 files changed, 271 insertions(+), 79 deletions(-) diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index bd3fc848b..ef6396627 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -1253,17 +1253,19 @@ the root, read-only or read-write. Neither exposes its fields: each field has a getter and a setter, and each list of `HostFsSharing` a method that adds an entry, as its option does. `HostFsSharing::plan` plans their export with NVX's `nvx::host::filesystem::plan`, through an `ExportRequest` of a mapping for each -share at its target, the host paths that it hides inside the shares, and the -guest targets that the root keeps: those of NVX's container root, -`ReservedTargets::CONTAINER_ROOT`, and the trees that the adapter keeps for -itself. Each share must lie inside a host directory that the adapter -authorizes, once links are resolved: `HostFsSharing::plan` refuses one outside -them before it plans, and checks the export's directories and files, which the -planner resolves anew, again. The planner refuses what it cannot export, such -as a target that takes or covers a reserved tree, or a read-only share inside -a read-write one, which the host could not keep read-only, and a hidden -path that does not exist inside a share, which nothing would hide once a -workload creates it. +share at its target, the host paths that it hides inside the shares, the host +paths inside hidden ones that it shows again, with the directories on the way +to them listing only them, and the guest targets that the root keeps: those of +NVX's container root, `ReservedTargets::CONTAINER_ROOT`, and the trees that +the adapter keeps for itself. Each share must lie inside a host directory that +the adapter authorizes, once links are resolved: `HostFsSharing::plan` refuses +one outside them before it plans, and checks the export's directories and +files, which the planner resolves anew, again. The planner refuses what it +cannot export, such as a target that takes or covers a reserved tree, or a +read-only share inside a read-write one, which the host could not keep +read-only, a hidden path that does not exist inside a share, which nothing +would hide once a workload creates it, and a path to show again that lies +inside no hidden one. `LayeredLaunch::share` attaches the export to the launch, which `LayeredLaunch::export` returns, and announces its table, of at most 4,096 @@ -1370,6 +1372,15 @@ The steps of a launch, in order, then that of a command that reattaches, are: A value that names no step or action is a usage error. Without the variable, the hook does nothing. +`start` takes the hook too, at `checked` alone, which it reaches once NVX's +`ClonePlan` checked the [clone](#clones) against the leased generation: the +bindings of its images, whose checked files the plan holds, and which claim its +scratch image, if it has one, its processor and memory targets, its network, +and the export of its shares, if any. The start has recorded nothing of the +clone yet, and spawned nothing, and the clone's launch checks the export again, +so a test may change a shared object there. A start refuses a hook at another +step as a usage error. + ### Templates The adapter's operations on templates, and on their clones, are commands of @@ -1787,15 +1798,16 @@ Each command of `runtime-adapter-host` but `stage`, `list`, `inspect`, and bindings that it checked. SHARES, - `--share HOST:TARGET[:ro]... [--deny PATH]... [--reserved TARGET]... [--share-root DIR]...`, + `--share HOST:TARGET[:ro]... [--deny PATH]... [--allow PATH]... [--reserved TARGET]... [--share-root DIR]...`, shares each host directory or regular file HOST with the root, at the absolute guest path TARGET, which starts at the last `:/`, read-only with `:ro`, as [Shares](#shares) describes. Each HOST must lie inside a host directory DIR that the adapter authorizes, the export hides each host path - PATH inside the shares, and no TARGET may take or cover a guest tree that the - container root keeps, or a tree TARGET that the adapter keeps. The launch - refuses shares that it cannot export before it touches STORE, and checks the - export before it spawns the VM. + PATH of `--deny` inside the shares, but shows each host path PATH of + `--allow` inside a hidden one again, and no TARGET may take or cover a guest + tree that the container root keeps, or a tree TARGET that the adapter keeps. + The launch refuses shares that it cannot export before it touches STORE, and + checks the export before it spawns the VM. Once attached, the launch follows the [host's order](#the-hosts-order): it sends the root configuration, checks that the guest agent that assembled the @@ -2047,8 +2059,9 @@ unless the teardown ended with 4, 8, or 9, which `run` then exits with, after the diagnostic. A value of `RUNTIME_ADAPTER_HOST_TIME` that is not a host's time is a usage error, as is a value of `RUNTIME_ADAPTER_STEP_HOOK` that sets no step -hook, with status 2. Without arguments, the program reports the contract that -it was built against. +hook, or that sets one at another step than `checked` for a start, with +status 2. Without arguments, the program reports the contract that it was +built against. ## Tests @@ -2516,7 +2529,8 @@ Windows: - shares: their text, `HOST:TARGET[:ro]`; the export of a table of shares inside an authorized directory, which maps each at its target, parents first, hides a denied path, and narrows the writes of a read-only directory - to the read-write share inside it; the root configuration that announces the + to the read-write share inside it, and an export that shows an allowed path + inside a hidden one again; the root configuration that announces the table, OpenVMM's arguments that attach the export, a launch that shares nothing, the export that the launch verifies before it prepares anything, and a table that the guest would refuse; @@ -2525,7 +2539,8 @@ Windows: not one, or a share that is not accessible; reserved trees that are not canonical guest paths, targets that take or cover the adapter's or the container root's reserved targets, a read-only share inside a read-write - one, and a hidden path that does not exist inside a share; + one, a hidden path that does not exist inside a share, and an allowed path + that lies inside no hidden one; - the networks that a launch reads: the OpenVMM arguments of each element of a policy, which NVX's lowering renders after the plan's block devices, a policy that lets nothing through, which attaches no network device, the proxy's @@ -2587,10 +2602,12 @@ host-only configuration, on Linux and Windows, without a hypervisor: Windows, the [step hook](#the-step-hook): the values of `RUNTIME_ADAPTER_STEP_HOOK` that set it, with each step and action, a path with colons, and those that set none, which the host program refuses with status -2 for `launch`, `reattach`, and `stop`; and a pause, which does nothing, and -pauses for no time, at another step, creates its file with its step's name at -its own, waits until the file is gone, on Windows until a pending deletion of -the file completes, and says how long it paused, and which a file that is +2 for `launch`, `reattach`, and `stop`; a start's, which the host program takes +at `checked`, and refuses at the other steps with status 2; and a pause, which +does nothing, and pauses for no time, at another step, creates its file with +its step's name at its own, waits until the file is gone, on Windows until a +pending deletion of the file completes, and says how long it paused, and which +a file that is there already fails. `tests/vm_steps.rs` checks the step hook on a real hypervisor, in the @@ -2985,9 +3002,9 @@ layered roots, captures of templates, and the starts and runs of their clones. | `layered_launch` | `a_launch_reads_only_the_networks_that_the_plan_takes` | A launch refuses the network files that the plan refuses, each for the plan's reason | | `layered_launch` | `a_launch_records_what_it_launched` | A launch's record holds the bindings that it checked, over a scratch image and over RAM, and a VM without a layered root has one too | | `layered_launch` | `a_launch_refuses_images_that_are_not_staged_or_changed_since` | A launch refuses images that the store does not stage, of another format or identity, and images that changed, were replaced, or are gone since they were staged | -| `layered_launch` | `a_launch_refuses_shares_that_it_cannot_export` | A launch refuses shares outside the authorized directories, inaccessible ones, reserved targets, a read-only share inside a read-write one, and a hidden path that does not exist | +| `layered_launch` | `a_launch_refuses_shares_that_it_cannot_export` | A launch refuses shares outside the authorized directories, inaccessible ones, reserved targets, a read-only share inside a read-write one, a hidden path that does not exist, and an allowed path inside no hidden one | | `layered_launch` | `a_launch_renders_each_element_of_its_network_through_the_plan` | OpenVMM's arguments of each element of a policy, after the plan's block devices, the proxy's URL, and the publication of listeners, as NVX's lowering renders them | -| `layered_launch` | `a_launch_shares_host_paths_inside_the_authorized_directories` | The export of shares inside an authorized directory maps each at its target, parents first, hides a denied path, and comes with the root configuration and OpenVMM's arguments that attach it | +| `layered_launch` | `a_launch_shares_host_paths_inside_the_authorized_directories` | The export of shares inside an authorized directory maps each at its target, parents first, hides a denied path, shows an allowed path inside a hidden one again, and comes with the root configuration and OpenVMM's arguments that attach it | | `layered_launch` | `a_layered_launch_hands_openvmm_the_checked_images` | OpenVMM's arguments name the checked files, by the descriptors that OpenVMM inherits on Linux and by their final paths on Windows, without a workload identity or a managed lifecycle | | `layered_launch` | `a_layered_launch_passes_what_the_guest_checks_as_it_is` | A launch passes on the options that the guest checks as they are | | `layered_launch` | `a_layered_launch_plans_its_blocks_and_boot_tokens_with_the_adapters_plan` | The adapter's plan of a layered launch: its block devices and boot tokens, without the sandbox profile's settings, and the sizes of the images | diff --git a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs index a4ccff068..66d60b536 100644 --- a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs +++ b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs @@ -46,17 +46,19 @@ //! that the checks opened, which it holds until the guest has assembled the root, or until it has //! torn down a launch that failed, and records the plan, with its network, and the bindings. //! -//! SHARES, `--share HOST:TARGET[:ro]... [--deny PATH]... [--reserved TARGET]... -//! [--share-root DIR]...`, shares each host directory or regular file HOST with the root, at the -//! absolute guest path TARGET, read-only with `:ro`, and read-write otherwise. The guest path -//! starts at the last `:/`. Each HOST must lie inside a host directory DIR, which the adapter -//! authorizes, once links are resolved: the launch refuses one outside them before it plans the -//! export, with NVX's `filesystem::plan`. The export hides each host path PATH inside the -//! shares, and no TARGET may take or cover a guest tree that the container root keeps, or a tree -//! TARGET that `--reserved` keeps for the adapter. The launch refuses a request that -//! `filesystem::plan` refuses, such as one with a read-only share inside a read-write one, -//! before it spawns the VM, and checks the export with `ExportPlan::verify` before it spawns it. -//! OpenVMM exports the shares; see `runtime_adapter::sharing::HostFsSharing`. +//! SHARES, `--share HOST:TARGET[:ro]... [--deny PATH]... [--allow PATH]... +//! [--reserved TARGET]... [--share-root DIR]...`, shares each host directory or regular file +//! HOST with the root, at the absolute guest path TARGET, read-only with `:ro`, and read-write +//! otherwise. The guest path starts at the last `:/`. Each HOST must lie inside a host directory +//! DIR, which the adapter authorizes, once links are resolved: the launch refuses one outside +//! them before it plans the export, with NVX's `filesystem::plan`. The export hides each host +//! path PATH of `--deny` inside the shares, but shows again each host path PATH of `--allow` +//! inside a hidden one, with the directories on the way to it listing only it, and no TARGET may +//! take or cover a guest tree that the container root keeps, or a tree TARGET that `--reserved` +//! keeps for the adapter. The launch refuses a request that `filesystem::plan` refuses, such as +//! one with a read-only share inside a read-write one, before it spawns the VM, and checks the +//! export with `ExportPlan::verify` before it spawns it. OpenVMM exports the shares; see +//! `runtime_adapter::sharing::HostFsSharing`. //! //! Once attached, the launch follows the host's order; see `runtime_adapter::host`. It sends the //! root configuration, which announces how many mappings the export's table holds, and waits for @@ -401,9 +403,12 @@ //! For tests, `launch`, `reattach`, and `stop` take a step hook from the variable //! `RUNTIME_ADAPTER_STEP_HOOK`, which pauses the command at a named step, for a time that a launch //! leaves out of its deadline, or exits it there without unwinding, with status 5; see -//! `runtime_adapter::hook`. `start` and `run` take the -//! host's time, at which the store checks the generation's eligibility, from the variable -//! `RUNTIME_ADAPTER_HOST_TIME`, if it is set, as a clock fixture, such as another boot's; see +//! `runtime_adapter::hook`. `start` takes one at `checked` alone: once NVX's `ClonePlan` checked +//! the clone against the leased generation, and holds the files that its checks opened, before the +//! start records the clone, and before the clone's launch checks its export again. `start` and +//! `run` take the host's time, at which the store checks the generation's eligibility, from the +//! variable `RUNTIME_ADAPTER_HOST_TIME`, if it is set, as a clock fixture, such as another boot's; +//! see //! `runtime_adapter::clone::HOST_TIME_VARIABLE`. //! //! They exit with 0 once they did what they were asked, `exec` once every execution finished, @@ -420,8 +425,8 @@ //! does once its execution failed to run, after the teardown, unless the teardown ended with 4, 8, //! or 9, as when the clone's VM exited during the execution, which `run` then exits with, after //! the diagnostic. Anything else is a usage error, with status 2, as is a value of -//! `RUNTIME_ADAPTER_STEP_HOOK` that sets no step hook, or of `RUNTIME_ADAPTER_HOST_TIME` that is -//! not a host's time. +//! `RUNTIME_ADAPTER_STEP_HOOK` that sets no step hook, or a step hook of a start at another step +//! than `checked`, or a value of `RUNTIME_ADAPTER_HOST_TIME` that is not a host's time. use std::env; use std::error::Error; @@ -463,7 +468,7 @@ use runtime_adapter::clone::{CloneRecord, host_time_from_env}; use runtime_adapter::exec::{self, AccountMode, HostExecution, Start}; use runtime_adapter::frame::{MAX_BODY, Message}; use runtime_adapter::generations::{self, Entry}; -use runtime_adapter::hook::{Step, StepHook}; +use runtime_adapter::hook::{STEP_HOOK, Step, StepHook}; use runtime_adapter::host::{self, Client, ClientError, CloneOrder, Executions, OrderError}; use runtime_adapter::initramfs; use runtime_adapter::layered::{Bindings, LaunchRecord, LayeredLaunch, read_network, with_proxy}; @@ -562,8 +567,8 @@ const USAGE: &str = concat!( " [--overlay-mode MODE] [--scratch-journal POLICY]", " [--first-partition-bound BYTES]\n", " [--workload-identity UID:GID] [--network FILE]\n", - " [--share HOST:TARGET[:ro]... [--deny PATH]... [--reserved TARGET]...", - " [--share-root DIR]...]]\n", + " [--share HOST:TARGET[:ro]... [--deny PATH]... [--allow PATH]...\n", + " [--reserved TARGET]... [--share-root DIR]...]]\n", " runtime-adapter-host (create | refresh) STORE --guest-agent PATH --distro FORMAT:PATH", " --distro-identity UUID\n", " [--hypervisor NAME] [--guest-build BUILD] [--cpu-profile PROFILE]", @@ -583,8 +588,8 @@ const USAGE: &str = concat!( " [--scratch-journal POLICY]\n", " [--first-partition-bound BYTES] [--machine-id (image | generated)]", " [--network FILE]\n", - " [--share HOST:TARGET[:ro]... [--deny PATH]... [--reserved TARGET]...", - " [--share-root DIR]...]\n", + " [--share HOST:TARGET[:ro]... [--deny PATH]... [--allow PATH]...\n", + " [--reserved TARGET]... [--share-root DIR]...]\n", " runtime-adapter-host run STORE --template TEMPLATES --generation G", " --distro FORMAT:PATH\n", " --distro-identity UUID --upper (scratch:PATH | ram) [--guest-build BUILD]", @@ -593,8 +598,8 @@ const USAGE: &str = concat!( " [--scratch-journal POLICY]\n", " [--first-partition-bound BYTES] [--machine-id (image | generated)]", " [--network FILE]\n", - " [--share HOST:TARGET[:ro]... [--deny PATH]... [--reserved TARGET]...", - " [--share-root DIR]...] EXECUTION\n", + " [--share HOST:TARGET[:ro]... [--deny PATH]... [--allow PATH]...\n", + " [--reserved TARGET]... [--share-root DIR]...] EXECUTION\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,...]", @@ -693,6 +698,8 @@ enum Command { request: Box, /// The host's time, for tests, if `RUNTIME_ADAPTER_HOST_TIME` sets one. time: Option, + /// The step hook, for tests, if `RUNTIME_ADAPTER_STEP_HOOK` sets one, at `checked`. + hook: Option, }, /// Start a clone of a generation of a template, run one execution in its root, and tear it /// down. @@ -952,7 +959,7 @@ struct Options { machine_id: Option, /// `--workload-identity`. workload_identity: Option<(u32, u32)>, - /// `--share`, `--deny`, `--reserved`, and `--share-root`, in order. + /// `--share`, `--deny`, `--allow`, `--reserved`, and `--share-root`, in order. sharing: HostFsSharing, /// `--network`. network: Option, @@ -1047,7 +1054,8 @@ fn main() -> ExitCode { guest_build, request, time, - } => start(&store, &guest_build, &request, time), + hook, + } => start(&store, &guest_build, &request, time, hook.as_ref()), Command::Run { store, guest_build, @@ -1073,7 +1081,7 @@ fn main() -> ExitCode { } /// Parses the program's arguments, with the step hook that `RUNTIME_ADAPTER_STEP_HOOK` sets for a -/// launch, or a reattach or a stop, or says why they are not a command. +/// launch, a start, a reattach, or a stop, or says why they are not a command. fn parse(arguments: Vec) -> Result { let mut arguments = arguments.into_iter(); let Some(name) = arguments.next() else { @@ -1238,6 +1246,7 @@ fn parse(arguments: Vec) -> Result { guest_build, request: Box::new(clone_options(&name, options)?), time: host_time_from_env()?, + hook: start_hook()?, }, "probe" if probes.is_empty() => return Err("probe needs a probe".to_owned()), "probe" => Command::Probe { @@ -1374,6 +1383,7 @@ fn parse_option( options.sharing.add_share(share); } ("launch" | "start", "--deny") => options.sharing.add_denied(value), + ("launch" | "start", "--allow") => options.sharing.add_allowed(value), ("launch" | "start", "--reserved") => options.sharing.add_reserved(text(value)?), ("launch" | "start", "--share-root") => options.sharing.add_root(value), ("launch" | "create" | "start", "--network") if options.network.is_none() => { @@ -1424,10 +1434,11 @@ fn parse_option( } /// Says why the options of `sharing`, of a launch, a start, or a run, are not a command's: -/// `--deny`, `--reserved`, or `--share-root` without a `--share`. +/// `--deny`, `--allow`, `--reserved`, or `--share-root` without a `--share`. fn check_shares(sharing: &HostFsSharing) -> Result<(), String> { let options = [ ("--deny", !sharing.denied().is_empty()), + ("--allow", !sharing.allowed().is_empty()), ("--reserved", !sharing.reserved().is_empty()), ("--share-root", !sharing.roots().is_empty()), ]; @@ -1439,6 +1450,19 @@ fn check_shares(sharing: &HostFsSharing) -> Result<(), String> { Ok(()) } +/// Returns the step hook that `RUNTIME_ADAPTER_STEP_HOOK` sets for a start, if any, or says why it +/// sets none: a start takes it at `checked` alone, once NVX's `ClonePlan` checked the clone. +fn start_hook() -> Result, String> { + match StepHook::from_env()? { + Some(hook) if hook.step != Step::Checked => Err(format!( + "{STEP_HOOK} names the step {}, but a start takes the hook at {} alone", + hook.step, + Step::Checked + )), + hook => Ok(hook), + } +} + /// 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. @@ -2364,12 +2388,13 @@ impl Drop for TemporaryDirectory { /// Starts a clone of the generation of the template store that `clone` names, as it asks, in the /// sandbox store `store`, which it creates if needed, with the guest agent's build `guest_build`, /// at the host's time `time`, or at the time that `HostTime::now` samples without one, as -/// [`start_clone`] does, and leaves it running. +/// [`start_clone`] does, with the step hook `hook`, if any, and leaves it running. fn start( store: &Path, guest_build: &str, clone: &CloneOptions, time: Option, + hook: Option<&StepHook>, ) -> Result { // A network that the plan refuses fails the start before anything else, as it fails a launch, // then an image, or shares, that it refuses. @@ -2377,7 +2402,7 @@ fn start( // The clone keeps running once the command lets go of its VM, its session, and its sandbox's // lock, as once a launch committed. Ok( - match start_clone(store, guest_build, clone, planned, time)? { + match start_clone(store, guest_build, clone, planned, time, hook)? { Ok(_running) => ExitCode::SUCCESS, Err(status) => status, }, @@ -2401,7 +2426,8 @@ struct Running { /// Starts a clone of the generation of the template store that `clone` names, as `planned`, its /// plan, asks, in the sandbox store `store`, which it creates if needed, with the guest agent's /// build `guest_build`, at the host's time `time`, or at the time that `HostTime::now` samples -/// without one, and prints what it did. Returns the clone, which runs, once it committed the +/// without one, and prints what it did. The step hook `hook`, if any, acts at `checked`, once +/// NVX's `ClonePlan` checked the clone. Returns the clone, which runs, once it committed the /// clone's launch, or the status with which the command ends otherwise: once the template store /// refused the selection, or the clone failed, as [`clone_failed`] ends it. fn start_clone( @@ -2410,6 +2436,7 @@ fn start_clone( clone: &CloneOptions, planned: PlannedClone, time: Option, + hook: Option<&StepHook>, ) -> Result, String> { let PlannedClone { request, @@ -2508,6 +2535,7 @@ fn start_clone( memory, runtime: &runtime, guest_build, + hook, }; match clone.run(&mut acquired) { Ok((vmm, client)) => Ok(Ok(Running { @@ -2618,6 +2646,8 @@ struct CloneLaunch<'a> { runtime: &'a Runtime, /// The guest agent's build. guest_build: &'a str, + /// The step hook, if any, which acts at `checked` alone. + hook: Option<&'a StepHook>, } impl CloneLaunch<'_> { @@ -2625,7 +2655,8 @@ impl CloneLaunch<'_> { /// 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. Returns the clone's VM, and the session of the adapter's order - /// in which its sandbox started. + /// in which its sandbox started. The step hook, if any, acts once the plan checked the clone, + /// before the clone is recorded. /// /// 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 @@ -2642,6 +2673,7 @@ impl CloneLaunch<'_> { memory, runtime, guest_build, + hook, } = self; let staged = Staged::read(acquired.locked.dir()) .map_err(|error| Failed::Command(describe(&error)))?; @@ -2665,6 +2697,10 @@ impl CloneLaunch<'_> { Instant::now() + LOCK_TIMEOUT, ) .map_err(Failed::Clone)?; + // The plan checked the clone against the generation, and holds the files that the checks + // opened, but nothing records the clone yet, and nothing runs: a test may change what the + // clone's launch checks again. + step(hook, Step::Checked).map_err(Failed::Command)?; // The mappings that the clone's record announces, which the restore sequence sends. let mappings = clone_plan.activation().configuration().mappings; let record = CloneRecord { @@ -3402,7 +3438,7 @@ fn run( locked, vmm, mut client, - } = match start_clone(store, guest_build, clone, planned, time)? { + } = match start_clone(store, guest_build, clone, planned, time, None)? { Ok(running) => running, Err(status) => return Ok(status), }; diff --git a/tests/runtime-adapter/src/hook.rs b/tests/runtime-adapter/src/hook.rs index b18bf3084..ea6903791 100644 --- a/tests/runtime-adapter/src/hook.rs +++ b/tests/runtime-adapter/src/hook.rs @@ -1,7 +1,8 @@ //! The host program's step hook, for tests: at a named [`Step`] of a launch, or of a command that //! reattaches to a VM, it pauses the program, so that a test can act between two steps, as when it //! replaces a staged image once the launch has checked it, or ends a clone's VM once a stop found -//! it, or it exits the program without unwinding, as a crash would end it there. +//! it, or it exits the program without unwinding, as a crash would end it there. A clone's start +//! takes it at [`Step::Checked`] alone. //! //! The variable [`STEP_HOOK`] names the step, and what the hook does at it, its [`Action`]: //! @@ -15,7 +16,8 @@ //! //! Without the variable, the hook does nothing. `runtime-adapter-host launch`, `reattach`, and //! `stop` read it, and refuse a value that names no step or action, as they refuse a command line -//! that is not a command. +//! that is not a command. `runtime-adapter-host start` reads it too, and refuses one that names +//! another step than `checked`. use std::env; use std::ffi::OsStr; @@ -41,7 +43,9 @@ pub enum Step { /// `checked`: the launch has checked the bindings of its images, and the export of its shares, /// if any, and holds the checked files, which claim the scratch image, but has begun nothing /// in the sandbox's store, and has spawned nothing. A launch without a layered root, which - /// checks nothing, reaches it too. + /// checks nothing, reaches it too. A clone's start reaches it once NVX's `ClonePlan` checked + /// the clone against the leased generation, and holds the files that its checks opened, but + /// before the start records the clone, and before the clone's launch checks its export again. Checked, /// `spawned`: the VMM runs, and the launch's journal records it, but the launch has not /// connected to the VM. diff --git a/tests/runtime-adapter/src/layered.rs b/tests/runtime-adapter/src/layered.rs index e3e330d5f..9c6b7343d 100644 --- a/tests/runtime-adapter/src/layered.rs +++ b/tests/runtime-adapter/src/layered.rs @@ -21,12 +21,13 @@ //! A launch may also share host directories and files with the root, as //! [`HostFsSharing`](crate::sharing::HostFsSharing): each at an absolute guest path in the root, //! read-only or read-write, inside the host directories that the adapter authorizes, with host -//! paths hidden inside them, and never at the guest targets that the root keeps for itself; see -//! [`crate::sharing`]. NVX's `filesystem::plan` plans their export, an `ExportPlan`, which the -//! launch attaches itself, since an adapter's plan holds none: OpenVMM exports the shares through -//! its aggregate virtio-fs device, and appends the boot tokens with which the guest mounts the -//! export, out of the root. The root configuration announces how many mappings the export's table -//! holds, which the host sends once the guest is ready; see [`LayeredLaunch::table`]. +//! paths hidden inside them, and paths inside hidden ones shown again, and never at the guest +//! targets that the root keeps for itself; see [`crate::sharing`]. NVX's `filesystem::plan` plans +//! their export, an `ExportPlan`, which the launch attaches itself, since an adapter's plan holds +//! none: OpenVMM exports the shares through its aggregate virtio-fs device, and appends the boot +//! tokens with which the guest mounts the export, out of the root. The root configuration +//! announces how many mappings the export's table holds, which the host sends once the guest is +//! ready; see [`LayeredLaunch::table`]. //! //! A launch may give the VM a network: a `SandboxNetwork`, the guest's address and the policy that //! OpenVMM enforces there, which [`read_network`] reads from a file in the serde form of a plan's diff --git a/tests/runtime-adapter/src/sharing.rs b/tests/runtime-adapter/src/sharing.rs index 51d2f8577..583b25440 100644 --- a/tests/runtime-adapter/src/sharing.rs +++ b/tests/runtime-adapter/src/sharing.rs @@ -1,7 +1,8 @@ //! The host directories and regular files that the host program shares with a layered root, as //! [`HostFsSharing`]: each a [`Share`] at an absolute guest path in the root, read-only or //! read-write, inside the host directories that the adapter authorizes, with host paths hidden -//! inside them, and never at the guest targets that the root keeps for itself. +//! inside them, and paths inside hidden ones shown again, and never at the guest targets that the +//! root keeps for itself. //! //! [`HostFsSharing::plan`] plans their export with NVX's `filesystem::plan`, an `ExportPlan`, //! which a launch attaches itself, since an adapter's plan holds none; see @@ -85,8 +86,9 @@ impl Share { } /// The host paths that a launch shares with the root, as the host program's options give them: -/// the shares, the host paths hidden inside them, the guest trees that the adapter keeps for -/// itself, and the host directories that the adapter authorizes. +/// the shares, the host paths hidden inside them, those inside hidden ones that stay visible, the +/// guest trees that the adapter keeps for itself, and the host directories that the adapter +/// authorizes. /// /// Each list has a getter, named after it, a setter, which replaces it, and a method that adds an /// entry after the others, as its option does. [`plan`](Self::plan) plans the export of the @@ -97,6 +99,8 @@ pub struct HostFsSharing { shares: Vec, /// Host paths that the export hides inside the shares: `--deny`. denied: Vec, + /// Host paths inside hidden ones that the export shows again: `--allow`. + allowed: Vec, /// Absolute guest paths of the trees that the adapter keeps for itself, beside those that the /// container root keeps: `--reserved`. reserved: Vec, @@ -137,6 +141,22 @@ impl HostFsSharing { self.denied.push(path.into()); } + /// Returns the host paths inside hidden ones that the export shows again: `--allow`. + pub fn allowed(&self) -> &[PathBuf] { + &self.allowed + } + + /// Sets the host paths inside hidden ones that the export shows again to `allowed`, in place + /// of the earlier ones. + pub fn set_allowed(&mut self, allowed: Vec) { + self.allowed = allowed; + } + + /// Adds `path` after the other host paths that the export shows again, as `--allow` does. + pub fn add_allowed(&mut self, path: impl Into) { + self.allowed.push(path.into()); + } + /// Returns the absolute guest paths of the trees that the adapter keeps for itself, beside /// those that the container root keeps: `--reserved`. pub fn reserved(&self) -> &[String] { @@ -178,9 +198,11 @@ impl HostFsSharing { /// Each share must lie inside one of the [`roots`](Self::roots) once links are resolved, which /// this checks before it plans, and again of the directories and files that the plan exports, /// which `filesystem::plan` resolves anew. The plan maps each share at its target, read-only or - /// read-write, hides the [`denied`](Self::denied) paths inside the shares, and refuses targets - /// that the container root keeps, `ReservedTargets::CONTAINER_ROOT`, or that would take or - /// cover one of the [`reserved`](Self::reserved) trees. + /// read-write, hides the [`denied`](Self::denied) paths inside the shares, but for the + /// [`allowed`](Self::allowed) paths inside them, which stay visible, with the directories on + /// the way to them listing only them, and refuses targets that the container root keeps, + /// `ReservedTargets::CONTAINER_ROOT`, or that would take or cover one of the + /// [`reserved`](Self::reserved) trees. /// /// Fails with [`ErrorKind::Unsupported`](nvx::ErrorKind::Unsupported) for a reserved tree that /// is not an absolute and canonical guest path, a root that is not an accessible directory, a @@ -243,6 +265,10 @@ impl HostFsSharing { name: "--deny", paths: &self.denied, }; + request.allowed = PathList { + name: "--allow", + paths: &self.allowed, + }; let export = filesystem::plan(&request)?; for child in export.iter().flat_map(|export| &export.children) { authorize(&roots, &child.root)?; diff --git a/tests/runtime-adapter/tests/clone_start.rs b/tests/runtime-adapter/tests/clone_start.rs index 72d7bc9ec..578dc06e5 100644 --- a/tests/runtime-adapter/tests/clone_start.rs +++ b/tests/runtime-adapter/tests/clone_start.rs @@ -219,6 +219,10 @@ fn the_host_program_refuses_options_that_do_not_make_a_start() { with(&["--upper", "ram", "--deny", "secret"]), "--deny needs --share", ), + ( + with(&["--upper", "ram", "--allow", "secret/shown"]), + "--allow needs --share", + ), ( with(&["--upper", "ram", "--reserved", "/opt/adapter"]), "--reserved needs --share", @@ -417,6 +421,7 @@ fn the_host_program_refuses_options_that_do_not_make_a_run() { "--machine-id takes image or generated, not \"random\"", ), (with(&["--deny", "/srv"]), "--deny needs --share"), + (with(&["--allow", "/srv/a"]), "--allow needs --share"), ( with(&["--network", "a.json", "--network", "b.json"]), "unexpected option \"--network\" of run", @@ -953,7 +958,7 @@ fn a_start_refuses_shares_that_it_cannot_export_before_anything_else() { 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] = [ + let cases: [(Vec, String); 6] = [ ( vec![format!("{outside}:/srv/data")], format!( @@ -973,6 +978,17 @@ fn a_start_refuses_shares_that_it_cannot_export_before_anything_else() { ], "where the host cannot keep it read-only".to_owned(), ), + ( + vec![ + format!("{sources}:/srv/data"), + "--allow".to_owned(), + inner.display().to_string(), + ], + format!( + "--allow entry {} lies inside no --deny entry", + inner.display() + ), + ), ( vec![ format!("{sources}:/srv/data"), diff --git a/tests/runtime-adapter/tests/layered_launch.rs b/tests/runtime-adapter/tests/layered_launch.rs index aefcf80a9..c18a601ec 100644 --- a/tests/runtime-adapter/tests/layered_launch.rs +++ b/tests/runtime-adapter/tests/layered_launch.rs @@ -620,6 +620,16 @@ fn a_launch_shares_host_paths_inside_the_authorized_directories() { assert_eq!(export.children[0].denied, [canonical(&dir.join("secret"))]); assert_eq!(export.children[0].write, [canonical(&dir.join("inner"))]); + // It shows an allowed path inside a hidden one again: here `out/sub` in `out`, which it hides. + let out = sources.join("out"); + let mut shown = sharing(&sources); + shown.add_denied(out.clone()); + shown.add_allowed(out.join("sub")); + let shown = shown.plan().unwrap().unwrap(); + assert_eq!(shown.children[2].denied, [canonical(&out)]); + assert_eq!(shown.children[2].allowed, [canonical(&out.join("sub"))]); + assert!(export.children.iter().all(|child| child.allowed.is_empty())); + // A target may lie below a path that the container root creates, though not at it. let mut below = sharing(&sources); retarget(&mut below, 2, "/etc/app/file.txt"); @@ -781,6 +791,15 @@ fn a_launch_refuses_shares_that_it_cannot_export() { &|request| request.add_denied(unborn.clone()), &format!("--deny entry {} does not exist", unborn.display()), ); + // An allowed path that no hidden path holds, which has nothing to show again. + let marker = dir.join("marker"); + refused( + &|request| request.add_allowed(marker.clone()), + &format!( + "--allow entry {} lies inside no --deny entry, so it has nothing to show again", + marker.display() + ), + ); // A link inside an authorized directory to one outside it. #[cfg(unix)] @@ -827,7 +846,7 @@ fn the_host_program_refuses_shares_before_it_touches_the_store() { }; let root = sources.as_os_str().to_owned(); let (dir, out) = (sources.join("dir"), sources.join("out")); - let cases: [(Vec, String); 4] = [ + let cases: [(Vec, String); 5] = [ ( vec![ "--share".into(), @@ -862,6 +881,17 @@ fn the_host_program_refuses_shares_before_it_touches_the_store() { ], "where the host cannot keep it read-only".to_owned(), ), + ( + vec![ + "--share".into(), + shared(&dir, ":/srv/dir"), + "--allow".into(), + dir.join("marker").into(), + "--share-root".into(), + root.clone(), + ], + "lies inside no --deny entry".to_owned(), + ), ( vec![ "--share".into(), @@ -1589,7 +1619,7 @@ fn the_host_program_refuses_a_network_before_it_launches() { fn the_host_program_refuses_options_that_do_not_make_a_command() { let launch = ["launch", "store", "--guest-agent", "agent"]; let uuid = "0a490905-41be-ed5f-984e-63eb7e5e20b1"; - let cases: [(&[&str], &str); 37] = [ + let cases: [(&[&str], &str); 38] = [ ( &["--distro", "erofs:image", "--upper", "ram"], "--distro needs --distro-identity", @@ -1715,6 +1745,7 @@ fn the_host_program_refuses_options_that_do_not_make_a_command() { (&["--distro"], "\"--distro\" needs a value"), (&["--share", "a:/b"], "--share needs --distro"), (&["--deny", "secret"], "--deny needs --share"), + (&["--allow", "secret/shown"], "--allow needs --share"), (&["--reserved", "/opt/adapter"], "--reserved needs --share"), (&["--share-root", "sources"], "--share-root needs --share"), ( diff --git a/tests/runtime-adapter/tests/step_hook.rs b/tests/runtime-adapter/tests/step_hook.rs index cdf3622e8..180d6069c 100644 --- a/tests/runtime-adapter/tests/step_hook.rs +++ b/tests/runtime-adapter/tests/step_hook.rs @@ -1,7 +1,8 @@ //! The host program's step hook, for tests: the values of `RUNTIME_ADAPTER_STEP_HOOK` that set it, -//! and those that the host program refuses, and a pause, which waits until its file is gone, on -//! Windows until a pending deletion of the file completes, and says how long it paused. The tests -//! need no hypervisor; the VM tests pause launches and stops with the hook, and end them. +//! and those that the host program refuses, a start's, which it takes at `checked` alone, and a +//! pause, which waits until its file is gone, on Windows until a pending deletion of the file +//! completes, and says how long it paused. The tests need no hypervisor; the VM tests pause +//! launches and stops with the hook, and end them. #![cfg(feature = "host")] use std::ffi::OsStr; @@ -12,6 +13,8 @@ use std::process::Command; use std::thread; use std::time::{Duration, Instant}; +use nvx::host::artifacts::Artifacts; +use runtime_adapter::clone::HOST_TIME_VARIABLE; use runtime_adapter::hook::{Action, HOOK_EXIT, STEP_HOOK, Step, StepHook}; #[test] @@ -204,3 +207,61 @@ fn the_host_program_refuses_a_command_whose_step_hook_it_cannot_read() { } } } + +#[test] +fn a_start_takes_a_step_hook_at_checked_alone() { + let directory = Path::new(env!("CARGO_TARGET_TMPDIR")).join("step-hook-start"); + let _ = fs::remove_dir_all(&directory); + fs::create_dir_all(&directory).unwrap(); + let image = directory.join("distro.erofs"); + fs::write(&image, vec![0; 4096]).unwrap(); + let store = directory.join("store"); + let start = |value: &str| { + Command::new(env!("CARGO_BIN_EXE_runtime-adapter-host")) + .arg("start") + .arg(&store) + .arg("--template") + .arg(directory.join("templates")) + .args(["--generation", "1", "--distro"]) + .arg(format!("erofs:{}", image.display())) + .args([ + "--distro-identity", + "0a490905-41be-ed5f-984e-63eb7e5e20b1", + "--upper", + "ram", + ]) + .env(STEP_HOOK, value) + .env_remove(Artifacts::ENV_DIR) + .env_remove(HOST_TIME_VARIABLE) + .output() + .unwrap() + }; + + // A hook at a step that a start does not stop at is a usage error, as one that names no step. + for (value, problem) in [ + ( + "spawned:exit", + "names the step spawned, but a start takes the hook at checked alone", + ), + ("ready:exit", "names the step ready"), + ("commit:pause:pause", "names the step commit"), + ("found:exit", "names the step found"), + ("nowhere:exit", "names the step \"nowhere\""), + ] { + let output = start(value); + assert_eq!(output.status.code(), Some(2), "{value:?}: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(problem) && stderr.contains("usage: runtime-adapter-host"), + "{value:?}: {stderr}" + ); + } + + // One at `checked` makes a start, whose selection the template store, which does not exist, + // refuses before the start plans the clone. + let output = start("checked:exit"); + assert_eq!(output.status.code(), Some(10), "{output:?}"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("failure.stage=selection\n"), "{stdout}"); + assert!(!store.exists(), "the start created the sandbox's store"); +} From 72e3dd6f3595c0b46d7f3acd58455cee0a71730e Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Wed, 28 Oct 2026 18:45:14 -0700 Subject: [PATCH 2/3] nvx-rs: attach a clone's export as an aggregate, as a cold launch does A template is a platform snapshot, and OpenVMM lets a platform restore attach an aggregate to a dormant filesystem slot, which it checks as a cold boot's (#540, OpenVMM 832d5bb4). So a clone takes any export plan that filesystem::plan makes for the container root, as a cold launch does: up to 256 host directories and files, each read-only or read-write, with OpenVMM's hidden, re-exposed, and writable paths, and the plan's owner, none of which may lie inside the template store or, for a directory, hold it. The restore renders the export as a cold launch renders it, with --mount-aggregate and a --mount-child of each directory and file, and the clone's mapping table is a cold launch's, whose sources name each path by its child's index. The checks that keep a clone's guest from the template store and from the host's records in the clone's sandbox apply to each directory and file of the aggregate. A file holds no directory, and lets the guest write its contents alone, but a file in the sandbox's directory itself may be one of the host's records there, so the plan refuses one, read-only or writable, as it refuses a directory that is or holds the sandbox's. This replaces stage 1's interim rule (#846), which took an export of one directory alone, bound it with --mount, and sent sources without the child's index. The activation record names the export as an aggregate, which the guest mounts and maps as it did. The protocol and the guest still take an empty source, which names the root of a directory export; the design document, the vectors' README, and the README now say that NVX's hosts, whose exports are aggregates, send none. The adapter's clone order sends a cold launch's table, and its documentation describes the aggregate. Its VM scenarios follow in the next commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: afd23653-4313-4e60-ab6b-350dffc1890b --- .../4.4-host-guest-control-interface.md | 14 +- nvx-rs/README.md | 63 ++-- nvx-rs/src/host/template/clone.rs | 101 +++--- nvx-rs/src/host/template/clone/export.rs | 159 ++++----- nvx-rs/src/host/template/clone/failure.rs | 10 +- nvx-rs/src/host/template/clone/launch.rs | 19 +- nvx-rs/src/host/template/clone/record.rs | 6 +- nvx-rs/src/host/template/clone/tests.rs | 309 ++++++++++++++---- .../src/host/template/clone/tests/launch.rs | 50 ++- .../src/host/template/clone/tests/restore.rs | 6 +- nvx-rs/src/host/template/mod.rs | 12 +- nvx-rs/tests/vectors/README.md | 9 +- tests/runtime-adapter/README.md | 31 +- tests/runtime-adapter/scenarios.txt | 2 +- .../src/bin/runtime-adapter-host.rs | 6 +- tests/runtime-adapter/src/clone.rs | 10 +- tests/runtime-adapter/tests/host_session.rs | 17 +- 17 files changed, 504 insertions(+), 320 deletions(-) diff --git a/doc/design/4.4-host-guest-control-interface.md b/doc/design/4.4-host-guest-control-interface.md index 7c6c7a7e7..7a0dfbd12 100644 --- a/doc/design/4.4-host-guest-control-interface.md +++ b/doc/design/4.4-host-guest-control-interface.md @@ -30,9 +30,10 @@ Table 4.2 lists the operations of the control protocol. 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. +names the export's root itself, as the table of a host whose export is one +directory maps the whole directory. NVX's hosts, whose exports are aggregates +of host directories and files, whose roots list them, send none, and NVX's +managed agent refuses one. 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 @@ -135,8 +136,9 @@ 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. 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 +vector until an empty source came to name the export's root, for a host whose +export is one directory. NVX's hosts, whose exports are aggregates, send none, +and a host whose export is one directory sends one only to a guest that +deploys with it, 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 d7877a97c..c83be549c 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -684,21 +684,21 @@ which nothing formats, copies, or reads, or RAM, whose restore declares the template's scratch absent, so that OpenVMM attaches it with no file and the plan creates none; 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 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. +container root, an aggregate of up to 256 host directories and files, as a cold +launch exports, none of which may overlap the template store, by the paths that +the plan and the store's directory resolve to, 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, or be, hold, or for +a file lie in 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 paths again at each launch and restore, so that the directories +above the export's directories and files 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, @@ -709,8 +709,8 @@ 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; the export, a -directory, with its plan's identity, and its mappings, the plan's binds, from 1 +or the scratch image's binding identity with the journal policy; the export, an +aggregate, 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 clone's network, with its identity, the DNS server that OpenVMM serves the guest at a gateway under its policy, if any, whether the guest publishes its @@ -743,8 +743,9 @@ named pipe of a random name on Windows; and it runs `supervisor::launch` with OpenVMM's restore of the clone's devices, with `--restore-absent-scratch` for a clone in RAM, and of its network, whose policy `--restore-network-policy` installs on the template's NIC in place of the complete deny-all policy, its -export bound to the dormant slot, `--mount` with -the directory, and its hidden, re-exposed, and writable paths and its owner, +export bound to the dormant slot as a cold launch attaches it, +`--mount-aggregate` with a `--mount-child` of each of its directories and files, +their 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, `--restore-processors` with the @@ -784,16 +785,16 @@ in at most `MAX_ACTIVATION_SESSIONS` sessions by the same deadline, and retries 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, 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. +through `CloneControl::map`, in order, as a cold launch's guest receives it, +each path by its 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 @@ -1032,8 +1033,8 @@ 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 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. +whose root lists its children; NVX's hosts, whose exports are all aggregates, +send none; a layered `Root` applies its live shares and mappings through it. ### `nvx::guest::nvxs` diff --git a/nvx-rs/src/host/template/clone.rs b/nvx-rs/src/host/template/clone.rs index c11db28dc..6292aee49 100644 --- a/nvx-rs/src/host/template/clone.rs +++ b/nvx-rs/src/host/template/clone.rs @@ -33,7 +33,7 @@ use std::io; use std::path::{Component, Path, PathBuf}; use std::time::Instant; -use self::export::DirectoryExport; +use self::export::CloneExport; use self::failure::{Attribute, Fault, host_evidence}; use super::key::TemplateKey; use super::manifest::{BlockGeometry, PROCESSOR_COUNTS}; @@ -95,8 +95,8 @@ pub struct CloneRequest { /// [`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. + /// dormant. The restore binds the export to the slot as a cold launch attaches it, an + /// aggregate of the plan's directories and files, as [`ClonePlan`] describes. pub export: Option, /// The processor target: the processors online in the clone's VM once restored, a prefix of /// the template's processor capacity, of which the restore brings online those beyond the @@ -205,30 +205,37 @@ pub enum CloneUpper { /// so a template serves the clones of every network policy at its guest's address: egress /// rules, a proxy, ingress, host-loopback access, and host-loopback forwards alike. /// - 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 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 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: an aggregate of up to +/// [`MAX_CHILDREN`](crate::host::filesystem::MAX_CHILDREN) host directories and files, each +/// read-only or read-write, with OpenVMM's hidden, re-exposed, and writable paths, and its +/// owner. A template's filesystem slot is dormant, and the restore of a template, a platform +/// snapshot, binds the aggregate to it as a cold launch attaches one, so the sources of the +/// plan's binds name their paths by their child's index, as the mapping table, +/// [`table`](Self::table), sends them, as at a cold launch. None of its directories and files +/// may lie inside the template store's directory, nor any of its directories 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 may the export 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 one of its directories 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 read or change, +/// or turn elsewhere, into a leased snapshot, say, nor may one of its files lie in that +/// directory itself, where it may be one of those records, though a directory inside the +/// sandbox's, among the caller's files, may be exported, as may a file in it. 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 the paths of an +/// export again at each launch and restore, so the directories above each of the export's +/// directories and files 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, its network, and the resources that the clone must @@ -321,7 +328,7 @@ pub struct ClonePlan { devices: SandboxPlan, /// The clone's export, which its restore binds to the template's dormant filesystem slot, if /// it has one. - export: Option, + export: Option, } /// How a plan reaches the clone's files: as the checks of their bindings reach them, or, in tests, @@ -375,20 +382,21 @@ impl ClonePlan { /// 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`]; 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`]; + /// finds it, such as one that maps paths but exports no directory or file, exports some but + /// maps no path, or exports more directories and files than OpenVMM attaches, + /// [`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`], 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 + /// - with [`ErrorKind::Unsupported`] for an export of which a directory or a file, by the path + /// that its plan resolved, lies inside the template store's directory, [`Store::dir`], by + /// the path that it resolves to, or of which a directory holds it, or is or holds the + /// directory of the clone's sandbox, [`Lease::sandbox`], or of which a file lies in that + /// directory itself, 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; + /// 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 @@ -649,19 +657,18 @@ impl ClonePlan { /// 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) + self.export.as_ref().map(CloneExport::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. + /// export plan, in their order, each from its path in the export, by its child's index, as a + /// cold launch's guest receives them, [`ExportPlan::guest_table`]. 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) + .map_or_else(Vec::new, CloneExport::table) } /// Returns the clone's activation record, which its guest applies in place of the @@ -676,7 +683,7 @@ 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; - /// the export, if the request has one, a directory, with the identity of its plan, and the + /// the export, if the request has one, an aggregate, 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 /// clone's network, if the template has a NIC, with the identity of the network, whose policy @@ -730,7 +737,7 @@ fn devices_of(request: &CloneRequest) -> SandboxPlan { /// Refuses a request that no clone takes, as [`ClonePlan::new`] describes, without reading or /// opening anything, and returns the request's export, if it has one, which the checks admitted. -fn check_request(request: &CloneRequest) -> Result, Fault> { +fn check_request(request: &CloneRequest) -> Result, Fault> { let adapter = &request.adapter; if adapter.settings().is_some() { return Err(Fault::new( @@ -780,7 +787,7 @@ fn check_request(request: &CloneRequest) -> Result, Faul request .export .as_ref() - .map(DirectoryExport::new) + .map(CloneExport::new) .transpose() .attribute(Evidence::InvalidMappings) } diff --git a/nvx-rs/src/host/template/clone/export.rs b/nvx-rs/src/host/template/clone/export.rs index 47907124b..808146920 100644 --- a/nvx-rs/src/host/template/clone/export.rs +++ b/nvx-rs/src/host/template/clone/export.rs @@ -5,50 +5,45 @@ //! 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. +//! own export to it, or none. A template is a platform snapshot, to whose dormant slot OpenVMM lets +//! a restore bind an aggregate, which it checks as it checks a cold boot's, before any processor +//! runs. So a clone exports what a cold launch exports, any export plan that `filesystem::plan` +//! makes for the container root: an aggregate of up to [`MAX_CHILDREN`] host directories and +//! files, each read-only or read-write, with OpenVMM's hidden, re-exposed, and writable paths, and +//! the plan's owner. The restore attaches it as a cold launch attaches it, +//! [`ExportPlan::openvmm_arguments`], and the clone's guest mounts its root, which lists its +//! children by their indices, so the clone's mapping table is a cold launch's, +//! [`ExportPlan::guest_table`], whose sources name each path by its child's index. +//! +//! [`MAX_CHILDREN`]: crate::host::filesystem::MAX_CHILDREN use std::path::{Path, PathBuf}; use crate::error::{Error, Result}; -use crate::host::filesystem::{ExportPlan, GUEST_EXPORT, Owner, ReservedTargets}; +use crate::host::filesystem::{ExportPlan, 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. +/// that [`filesystem::plan`](crate::host::filesystem::plan) could have made for the container +/// root, 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. +pub(super) struct CloneExport { + /// The plan. plan: ExportPlan, } -impl DirectoryExport { +impl CloneExport { /// 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. + /// that maps paths but exports no directory or file, exports some but maps no path, or exports + /// more directories and files than OpenVMM attaches. 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() - ))), - } + Ok(Self { plan: plan.clone() }) } /// Returns the export's plan. @@ -56,28 +51,30 @@ impl DirectoryExport { &self.plan } - /// Refuses the export if its directory lies inside the directory of a template store, - /// `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. 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. + /// Refuses the export if one of its directories or files lies inside the directory of a + /// template store, `resolved`, or one of its directories holds it: the guest could then reach + /// the payload of a generation that clones lease, and write it through a writable child, + /// though nothing may change a leased payload 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. /// - /// 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. + /// Refuses it too if one of its directories is, or holds, the directory of the clone's + /// sandbox, `sandbox`, which the lease names by its path with its links resolved, or if one of + /// its files lies in that directory itself: the host writes the clone's records there, by + /// their paths, so the guest could otherwise read or 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, nor is a file in such a + /// directory one of them. /// - /// 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 + /// The check reads nothing: it compares the paths of the export's directories and files, 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 @@ -85,9 +82,9 @@ impl DirectoryExport { /// 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 + /// it: OpenVMM resolves the paths of an export again at each launch and restore, so the + /// directories above each of the export's directories and files 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. @@ -117,8 +114,17 @@ impl DirectoryExport { sandbox.display() ))); } - // The guest may write anywhere in a writable directory, unless the plan limits its - // writes to some of the directory's paths. + // A file in the sandbox's directory itself may be one of the host's records there. + if child.file && child.root.parent() == Some(sandbox) { + return Err(Error::unsupported(format!( + "the export of a clone cannot share {}, a file in 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, and a writable file, 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), @@ -141,51 +147,22 @@ impl DirectoryExport { } /// 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. + /// clone is ready, as a cold launch's guest receives it, [`ExportPlan::guest_table`]: the + /// plan's binds, in their order, each from its path in the export, which a source names by + /// its child's index, 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() + self.plan.guest_table() } /// 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. + /// at a restore, as a cold launch attaches it, [`ExportPlan::openvmm_arguments`]: + /// `--mount-aggregate` with [`GUEST_EXPORT`](crate::host::filesystem::GUEST_EXPORT), a + /// `--mount-child` of each of its directories and files, named by its index, with its mode, + /// `rw` if the guest may write it, and `ro` otherwise, and `file` for a file; then their + /// 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 + self.plan.openvmm_arguments() } } diff --git a/nvx-rs/src/host/template/clone/failure.rs b/nvx-rs/src/host/template/clone/failure.rs index 56f4ea1e9..2f57c958a 100644 --- a/nvx-rs/src/host/template/clone/failure.rs +++ b/nvx-rs/src/host/template/clone/failure.rs @@ -98,11 +98,11 @@ pub enum Evidence { /// that the guest refused, as its /// [`ACTIVATION_RECORD`](crate::protocol::status::ACTIVATION_RECORD) tells. InvalidActivate, - /// 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 + /// The request's mappings are invalid: an export that no clone takes, such as 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 diff --git a/nvx-rs/src/host/template/clone/launch.rs b/nvx-rs/src/host/template/clone/launch.rs index 656d2b808..6e600e73f 100644 --- a/nvx-rs/src/host/template/clone/launch.rs +++ b/nvx-rs/src/host/template/clone/launch.rs @@ -134,12 +134,14 @@ impl ClonePlan { /// the template has a NIC: `--restore-network-policy`, with which the policy of the clone's /// network replaces the template's complete deny-all policy on the NIC, and the options of that /// policy, without `--net` and the guest's address, which OpenVMM takes from the 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, + /// clone's export, if it has one, follows, bound to the template's dormant filesystem slot as a + /// cold launch attaches it, as its [plan](ClonePlan::export) holds it, + /// [`ExportPlan::openvmm_arguments`](crate::host::filesystem::ExportPlan::openvmm_arguments): + /// `--mount-aggregate` with [`GUEST_EXPORT`](crate::host::filesystem::GUEST_EXPORT), a + /// `--mount-child` of each of its directories and files, with its mode, `rw` if a mapping + /// writes it, and `ro` otherwise, and `file` for a file, then their hidden, re-exposed, and + /// writable paths, and the caller owner; without an export, the slot stays dormant. Then come + /// `--single-process`, the template's backend, /// CPU profile, and processor capacity, `--restore-snapshot` with the directory of the /// generation's snapshot, [`ClonePlan::snapshot`], for a template that records its processors /// online at boot, [`TemplateKey::boot_processors`], `--restore-processors` with the processor @@ -207,8 +209,9 @@ impl ClonePlan { /// [`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 + /// directory or a file of the export that is no longer the kind of object that the export's + /// plan found, a mapped or hidden object that the guest could move that is no longer the + /// object that the plan found, or an object 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 diff --git a/nvx-rs/src/host/template/clone/record.rs b/nvx-rs/src/host/template/clone/record.rs index f717b30b8..3f6abaf23 100644 --- a/nvx-rs/src/host/template/clone/record.rs +++ b/nvx-rs/src/host/template/clone/record.rs @@ -56,8 +56,8 @@ const NETWORK_DOMAIN: &[u8] = b"nvx network policy\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; -/// - 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 +/// - the export, if the request has one: an aggregate, as every export plan is, 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 clone's network, if the template has a NIC, whose policy the restore enforces there in /// place of the template's complete deny-all policy: the identity of the network, its guest's @@ -126,7 +126,7 @@ pub(super) fn build( None => (None, 0), Some(plan) => ( Some(Export { - kind: ExportKind::Directory, + kind: ExportKind::Aggregate, plan: export_identity(plan).on_host()?, }), plan.binds.len(), diff --git a/nvx-rs/src/host/template/clone/tests.rs b/nvx-rs/src/host/template/clone/tests.rs index 4897ff725..7f9477c08 100644 --- a/nvx-rs/src/host/template/clone/tests.rs +++ b/nvx-rs/src/host/template/clone/tests.rs @@ -30,11 +30,12 @@ use crate::host::sandbox::scratch::CLAIM_RELEASE_WAIT; use crate::host::sandbox::{SandboxDistro, SandboxNetwork, SandboxSettings, SandboxUpper}; use crate::host::seal::{FileSeal, open_metadata_only}; use crate::host::store::{ - CLONE_ACTIVATION_NAME, CLONE_SCRATCH_NAME, CallerFiles, LOG_NAME, SandboxStore, + CLONE_ACTIVATION_NAME, CLONE_BINDING_NAME, CLONE_SCRATCH_NAME, CallerFiles, LOG_NAME, + SandboxStore, }; use crate::host::template::store::{CAPTURES_NAME, SNAPSHOT_NAME, TRANSACTION_NAME}; use crate::host::template::test_support::{MIB, ManifestSpec, class, network_class}; -use crate::host::template::{CaptureClass, HostTime}; +use crate::host::template::{CaptureClass, HostTime, KEY_NAME}; use crate::host::test_support::released; use crate::protocol::root::RootImage; use crate::uuid::Uuid; @@ -369,18 +370,18 @@ fn alias(directory: &Path) -> PathBuf { } } -/// 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. +/// Returns the mapping table of these tests' exports, [`Fixture::data_export`], as a cold launch's +/// guest receives it: the exported directory, child 0, and the directory `work` in it, each by its +/// path in the export, which names the child's index. fn data_table() -> [HostMap; 2] { [ HostMap { - source: String::new(), + source: "0".to_owned(), target: "/data".to_owned(), read_only: true, }, HostMap { - source: "work".to_owned(), + source: "0/work".to_owned(), target: "/work".to_owned(), read_only: false, }, @@ -540,8 +541,8 @@ impl Fixture { } /// 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`. + /// in which it writes the directory `work`, at `/work`, as [`data_table`] maps them: an + /// aggregate of one directory, whose writes the plan narrows to `work`. fn data_export(&self, name: &str) -> ExportPlan { let data = self.exported(name, &["work"]); export_plan( @@ -551,6 +552,35 @@ impl Fixture { ) } + /// Plans the export of an aggregate of the directory `name` among the exported directories, as + /// a cold launch exports one, and returns it with its directories and files, in the order of + /// their children: the directory `data`, which the guest reads at `/data`, but for its + /// directory `work`, which it writes at `/work`, and which hides its directory `secret`, but + /// for the directory `shown` in it; the file `notes.txt`, which the guest writes at + /// `/notes.txt`; the file `settings.json`, which it reads at `/settings.json`; and the + /// directory `tools`, which it reads at `/tools`. + fn aggregate_export(&self, name: &str) -> (ExportPlan, [PathBuf; 4]) { + let root = self.exported(name, &["data/work", "data/secret/shown", "tools"]); + let [data, notes, settings, tools] = + ["data", "notes.txt", "settings.json", "tools"].map(|entry| root.join(entry)); + for file in [¬es, &settings] { + fs::write(file, b"file").unwrap(); + } + let secret = data.join("secret"); + let export = export_plan( + &[ + (&data, "/data", true), + (&data.join("work"), "/work", false), + (¬es, "/notes.txt", false), + (&settings, "/settings.json", true), + (&tools, "/tools", true), + ], + std::slice::from_ref(&secret), + &[secret.join("shown")], + ); + (export, [data, notes, settings, tools]) + } + /// Provisions the sandbox `name`, and returns its directory, with its links resolved, as a /// lease names it. fn provision(&self, name: &str) -> PathBuf { @@ -2566,42 +2596,47 @@ fn only_a_path_that_no_longer_names_its_supplied_file_is_the_request_s() { } #[test] -fn a_clone_with_an_export_records_its_plan_and_maps_from_its_directory() { +fn a_clone_with_an_export_records_its_plan_and_maps_from_its_children() { 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"); + // An aggregate of directories and files, read-only and read-write, with hidden and re-exposed + // paths, as a cold launch exports one. + let (export, _) = fixture.aggregate_export("aggregate"); + assert_eq!(export.children.len(), 4); 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 + // The record names the export, an aggregate, 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, + kind: ExportKind::Aggregate, 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() + // The table is a cold launch's: its sources name each path by its child's index, as the + // aggregate's root lists the children. + assert_eq!(plan.table(), export.guest_table()); + let sources: Vec = plan + .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. + assert_eq!(sources, ["0", "1", "2", "3", "0/work"]); + // The identity covers the whole plan: another aggregate'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.kind, ExportKind::Aggregate); assert_eq!(recorded.plan, digest_of(b"nvx export plan\0", &other)); assert_ne!(recorded.plan, digest_of(b"nvx export plan\0", &export)); + assert_eq!(plan.table(), data_table()); // A clone without mappings has no export, and its record none, so its template's slot stays // dormant. drop(plan); @@ -2614,7 +2649,7 @@ fn a_clone_with_an_export_records_its_plan_and_maps_from_its_directory() { } #[test] -fn exports_that_a_dormant_filesystem_slot_does_not_bind_are_refused_before_anything_is_opened() { +fn exports_that_no_cold_launch_attaches_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"); @@ -2626,31 +2661,8 @@ fn exports_that_a_dormant_filesystem_slot_does_not_bind_are_refused_before_anyth 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. + // Mappings without an export, an export without mappings, and an export of more directories + // and files than OpenVMM attaches. let export = fixture.data_export("data"); let unexported = ExportPlan { children: Vec::new(), @@ -2669,6 +2681,15 @@ fn exports_that_a_dormant_filesystem_slot_does_not_bind_are_refused_before_anyth refusal(&unmapped), "the recorded export plan is invalid: it maps 0 paths, but a sandbox maps from 1 to 4096" ); + let crowded = ExportPlan { + children: vec![export.children[0].clone(); filesystem::MAX_CHILDREN + 1], + ..export.clone() + }; + assert_eq!( + refusal(&crowded), + "the recorded export plan is invalid: it exports 257 directories and files, but OpenVMM \ + exports from 1 to 256" + ); // 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(); @@ -2677,10 +2698,14 @@ fn exports_that_a_dormant_filesystem_slot_does_not_bind_are_refused_before_anyth message.starts_with("the recorded export plan is invalid"), "{message}" ); - // None of them opened anything, and the plan of one directory serves a clone. + // None of them opened anything, and an aggregate of directories and files serves a clone, as + // the export of one directory does. assert_untouched(&acquired); - let request = with_export(request, &export); - drop(fixture.plan(&mut acquired, &request, NO_CONTENT).unwrap()); + let (aggregate, _) = fixture.aggregate_export("aggregate"); + for export in [aggregate, export] { + let request = with_export(request.clone(), &export); + drop(fixture.plan(&mut acquired, &request, NO_CONTENT).unwrap()); + } } #[test] @@ -2698,7 +2723,7 @@ fn exports_that_overlap_the_template_store_are_refused_before_anything_is_opened // 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), + (payload.clone(), false), (fixture.directory.path().to_owned(), true), ] { let export = export_plan(&[(&dir, "/data", read_only)], &[], &[]); @@ -2718,6 +2743,31 @@ fn exports_that_overlap_the_template_store_are_refused_before_anything_is_opened ); assert_untouched(&acquired); } + // Every directory and file of an aggregate is checked: a file of the store's, beside a + // directory that is no part of it. + let beside = fixture.exported("beside", &[]); + let key = filesystem::canonicalize(&payload.join(KEY_NAME)).unwrap(); + let export = export_plan( + &[(&beside, "/beside", false), (&key, "/key.bin", true)], + &[], + &[], + ); + assert_eq!(export.children.len(), 2); + let request = with_export(request, &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", + key.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); @@ -2772,15 +2822,47 @@ fn exports_that_let_the_guest_turn_the_template_store_s_path_are_refused() { ); 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. + // Every directory of an aggregate is checked: a writable one beside a read-only directory + // through which the store's path does not lead. + let beside = fixture.exported("beside", &[]); + let export = export_plan( + &[(&beside, "/beside", true), (&hop, "/hop", false)], + &[], + &[], + ); + assert_eq!(export.children.len(), 2); + let plan = fixture.plan( + &mut acquired, + &with_export(request.clone(), &export), + 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", + hop.display(), + fixture.store.dir().display() + ) + ) + ); + assert_untouched(&acquired); + // A read-only export of the directory of a link, one in which the guest writes only a + // directory beside the link, and one of a file beside the link, whose content alone the + // guest writes, leave the link as it is. let work = share.join("work"); + let notes = share.join("notes.txt"); + fs::write(¬es, b"notes").unwrap(); for mappings in [ &[(share.as_path(), "/data", true)][..], &[ (share.as_path(), "/data", true), (work.as_path(), "/work", false), ], + &[(notes.as_path(), "/notes.txt", false)], ] { let export = export_plan(mappings, &[], &[]); let request = with_export(request.clone(), &export); @@ -2891,27 +2973,90 @@ fn exports_that_hold_the_sandbox_s_directory_are_refused() { } #[test] -fn the_export_of_a_clone_binds_its_directory_with_its_policy_and_owner() { +fn files_in_the_sandbox_s_directory_are_refused() { + // A file in the sandbox's directory itself may be one of the host's records, such as the + // clone's binding, which names its lease, and which a guest could otherwise read or change. + 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(); + let record = sandbox.join(CLONE_BINDING_NAME); + assert!(record.is_file(), "{}", record.display()); + // The record, read-only and writable, beside a directory that is no part of the sandbox's: an + // aggregate's every file is checked. + let beside = fixture.exported("beside", &[]); + for read_only in [true, false] { + let export = export_plan( + &[ + (&beside, "/beside", true), + (&record, "/clone.json", read_only), + ], + &[], + &[], + ); + assert_eq!(export.children.len(), 2); + let plan = fixture.plan( + &mut acquired, + &with_export(request.clone(), &export), + NO_CONTENT, + ); + assert_eq!( + refused(plan, Evidence::InvalidMappings), + ( + ErrorKind::Unsupported, + format!( + "the export of a clone cannot share {}, a file in the directory of the \ + clone's sandbox {}, in which the host writes the clone's records", + record.display(), + sandbox.display() + ) + ) + ); + assert_untouched(&acquired); + } + // A file in a directory inside the sandbox's, among the caller's files, is none of the host's + // records. + let shared = sandbox.join("shared"); + fs::create_dir(&shared).unwrap(); + let notes = shared.join("notes.txt"); + fs::write(¬es, b"notes").unwrap(); + let export = export_plan(&[(¬es, "/notes.txt", false)], &[], &[]); + assert!(export.children[0].file); + let request = with_export(request, &export); + drop(fixture.plan(&mut acquired, &request, NO_CONTENT).unwrap()); +} + +#[test] +fn the_export_of_a_clone_is_attached_and_mapped_as_a_cold_launch_attaches_and_maps_it() { let fixture = Fixture::new(); - let data = fixture.exported("data", &["work", "secret/shown"]); + let (mut export, [data, notes, settings, tools]) = fixture.aggregate_export("aggregate"); 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 child = |index: usize, path: &Path, mode: &str| { + format!("{index},{},{mode}", path.to_str().unwrap()) + }; 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); + let attached = CloneExport::new(&export).unwrap(); + assert_eq!(attached.plan(), &export); + // The restore attaches the aggregate with a cold launch's arguments: each directory and file + // a child, by its index, with its mode, `rw` if a mapping writes it, a file marked as one, + // then the paths that OpenVMM hides, shows again, and lets the guest write. The guest reads + // `data` but for `work`, and `secret` hides all of it but `shown`. + assert_eq!(attached.arguments(), export.openvmm_arguments()); assert_eq!( - directory.arguments(), + attached.arguments(), [ - "--mount".to_owned(), - mount("rw"), + "--mount-aggregate".to_owned(), + filesystem::GUEST_EXPORT.to_owned(), + "--mount-child".to_owned(), + child(0, &data, "rw"), + "--mount-child".to_owned(), + child(1, ¬es, "rw,file"), + "--mount-child".to_owned(), + child(2, &settings, "ro,file"), + "--mount-child".to_owned(), + child(3, &tools, "ro"), "--mount-deny".to_owned(), text(&secret), "--mount-allow".to_owned(), @@ -2920,17 +3065,39 @@ fn the_export_of_a_clone_binds_its_directory_with_its_policy_and_owner() { text(&work), ] ); + // Its table is a cold launch's, each mapping in the plan's order, by its child's index. + let mapping = |source: &str, target: &str, read_only| HostMap { + source: source.to_owned(), + target: target.to_owned(), + read_only, + }; + assert_eq!(attached.table(), export.guest_table()); + assert_eq!( + attached.table(), + [ + mapping("0", "/data", true), + mapping("1", "/notes.txt", false), + mapping("2", "/settings.json", true), + mapping("3", "/tools", true), + mapping("0/work", "/work", false), + ] + ); // 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(); + let arguments = CloneExport::new(&export).unwrap().arguments(); assert_eq!( arguments[arguments.len() - 2..], ["--mount-owner", "caller"] ); - // A directory that the guest only reads is bound read-only. + // An aggregate that the guest only reads is attached read-only. let read_only = export_plan(&[(&data, "/data", true)], &[], &[]); assert_eq!( - DirectoryExport::new(&read_only).unwrap().arguments(), - ["--mount".to_owned(), mount("ro")] + CloneExport::new(&read_only).unwrap().arguments(), + [ + "--mount-aggregate".to_owned(), + filesystem::GUEST_EXPORT.to_owned(), + "--mount-child".to_owned(), + child(0, &data, "ro"), + ] ); } diff --git a/nvx-rs/src/host/template/clone/tests/launch.rs b/nvx-rs/src/host/template/clone/tests/launch.rs index f5ec80dc8..d9da4e4e4 100644 --- a/nvx-rs/src/host/template/clone/tests/launch.rs +++ b/nvx-rs/src/host/template/clone/tests/launch.rs @@ -1192,7 +1192,7 @@ fn a_clone_with_an_export_binds_it_to_its_template_s_dormant_filesystem_slot() { 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 (export, _) = fixture.aggregate_export("aggregate"); 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(); @@ -1211,28 +1211,22 @@ fn a_clone_with_an_export_binds_it_to_its_template_s_dormant_filesystem_slot() { vmm.reference = Some(reference.clone()); vmm.started(); // The clone in RAM declares its scratch absent, and the export follows the clone's block - // device: 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"); + // device, as a cold launch attaches it: the aggregate, on the dormant slot, with its + // directories and files, their modes and policies. let arguments = made.take().unwrap().arguments; let devices = arguments .iter() .position(|argument| argument == "--single-process") .unwrap(); + assert_eq!(arguments[4], "--restore-absent-scratch"); + assert_eq!(arguments[5..devices], export.openvmm_arguments()); assert_eq!( - arguments[4..devices], - [ - "--restore-absent-scratch", - "--mount", - mount.as_str(), - "--mount-write", - work.to_str().unwrap() - ] + arguments[5..7], + ["--mount-aggregate", filesystem::GUEST_EXPORT] ); - // The restore sequence sends the export's table once the clone is ready. - assert_eq!(restoring.table, data_table()); + // The restore sequence sends the export's table once the clone is ready, as a cold launch's + // guest receives it. + assert_eq!(restoring.table, export.guest_table()); assert_eq!( restoring.abandon().unwrap(), Recovery::Terminated(reference) @@ -1315,7 +1309,11 @@ fn a_clone_whose_exported_objects_changed_since_its_plan_starts_nothing() { 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) + .plan( + &mut replaced, + &with_export(request.clone(), &export), + NO_CONTENT, + ) .unwrap(); fs::remove_dir_all(&data).unwrap(); fs::write(&data, b"file").unwrap(); @@ -1328,6 +1326,24 @@ fn a_clone_whose_exported_objects_changed_since_its_plan_starts_nothing() { changed(&data) ); assert_unlaunched(&fixture, &replaced); + // A file of an aggregate, which a directory has replaced since the plan, and which would + // grant the whole directory. + let mut swapped = fixture.acquire("swapped"); + let (export, [_, notes, ..]) = fixture.aggregate_export("swapped"); + let plan = fixture + .plan(&mut swapped, &with_export(request, &export), NO_CONTENT) + .unwrap(); + fs::remove_file(¬es).unwrap(); + fs::create_dir(¬es).unwrap(); + assert_eq!( + refused_at( + plan.launch_with(&mut swapped, Path::new(OPENVMM), &runtime, hooks), + Stage::Launch, + Evidence::InvalidMappings, + ), + changed(¬es) + ); + assert_unlaunched(&fixture, &swapped); } #[test] diff --git a/nvx-rs/src/host/template/clone/tests/restore.rs b/nvx-rs/src/host/template/clone/tests/restore.rs index 24823fce2..df33137b0 100644 --- a/nvx-rs/src/host/template/clone/tests/restore.rs +++ b/nvx-rs/src/host/template/clone/tests/restore.rs @@ -1441,10 +1441,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. + // The table names each path by its child's index, as a cold launch's does, through the + // `MAPS` payloads that carry it. control.assert_activates_first(); - assert_eq!(data_table()[0].source, ""); + assert_eq!(data_table().to_vec(), export.guest_table()); assert_eq!( control.calls[2..], [ diff --git a/nvx-rs/src/host/template/mod.rs b/nvx-rs/src/host/template/mod.rs index 573921701..cb0cad0fc 100644 --- a/nvx-rs/src/host/template/mod.rs +++ b/nvx-rs/src/host/template/mod.rs @@ -34,12 +34,12 @@ //! - [`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, 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 longer than the template's placeholder, the capacity of every -//! clone's image, or of other block sizes, a network that attaches a NIC where the template has -//! none, none where it has one, or one at another guest address than the template's, or an -//! export of several directories or of a file, before any VMM starts. +//! memory target against the template, and its export, if it has one, an aggregate of host +//! directories and files, as a cold launch exports it, which the template's dormant filesystem +//! slot binds, and refuses a request that the template cannot serve, such as an image longer +//! than the template's placeholder, the capacity of every clone's image, or of other block +//! sizes, or a network that attaches a NIC where the template has none, none where it has one, +//! or one at another guest address than the template's, 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 diff --git a/nvx-rs/tests/vectors/README.md b/nvx-rs/tests/vectors/README.md index cbc2c9e07..7a49fe671 100644 --- a/nvx-rs/tests/vectors/README.md +++ b/nvx-rs/tests/vectors/README.md @@ -63,10 +63,11 @@ the key that the vector's bytes decode to. bytes of an earlier version that it refuses become its rejection vectors. Version 1 has two exceptions. `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. And `CLASS|NETWORK` of + name the export's root, for a host whose export is one directory. NVX's + hosts, whose exports are aggregates, send none, and a host whose export is + one directory sends one only to a guest that deploys with it, so the C agent + of earlier releases, which refuses it, never receives one. And + `CLASS|NETWORK` of `template-key-v1.txt` came to encode its network in place as the template captures it, under the complete deny-all policy: the encodings of a key and of a class have not been released, so a template store that an earlier build diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index ef6396627..4d9c13ede 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -1281,7 +1281,9 @@ 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. A [clone's](#clones) start takes the same -shares, whose export NVX binds to the template's dormant filesystem slot. +shares, whose export NVX attaches to the template's dormant filesystem slot as +an aggregate, as a launch attaches it, and whose table it sends as a launch +sends it. ### Networks @@ -1612,11 +1614,13 @@ be the template's scratch's, its length included, its processor target, 1, 2, 4, or 8, from the template's processors online at boot to its capacity, which the restore brings online, and the clone's repair verifies, 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 +its export, if it has one, which it checks as a cold launch's: from 1 to 256 +directories and files, the outermost shares, each of which the template's +filesystem slot, which its capture left dormant, attaches as a child of an +aggregate, with from 1 to 4,096 mappings, none of them at a target that the +container root keeps, and none of the directories and files may lie inside the +template store's directory or 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 @@ -1630,9 +1634,11 @@ generation's snapshot in place, through NVX's supervisor, as the launch that the lease reserves, with the clone's image at the capacity of the template's placeholder, over the scratch image, or, for a clone in RAM, with the template's scratch absent, which OpenVMM attaches with no file, so that no -file of the host backs it, with the export's directory on the template's -filesystem slot, -and the template's NIC, if it has one, under the policy of the clone's +file of the host backs it, with the export on the template's filesystem slot, +which OpenVMM attaches as an aggregate, as a cold launch attaches it: each +directory and file a child, read-only or read-write, with the paths that the +export hides, shows again, or lets the guest write, and the template's NIC, if +it has one, under the policy of the clone's network, which `--restore-network-policy` installs in place of the template's complete deny-all policy, and the restore sequence activates the clone through the @@ -1640,8 +1646,9 @@ 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`, 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 +its plan, and the table holds its mappings, each from a source that names its +child by the child's index, as a cold launch's table does. 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 @@ -2969,7 +2976,7 @@ layered roots, captures of templates, and the starts and runs of their clones. | `clone_start` | `the_host_program_refuses_options_that_do_not_make_a_run` | The command lines of `run` that the host program refuses, its execution's included, each with status 2, and a clock fixture that is not a host's time | | `clone_start` | `the_host_program_refuses_options_that_do_not_make_a_start` | The command lines of `start` that the host program refuses, each with status 2, and a clock fixture that is not a host's time | | `host_session` | `a_clone_s_order_ends_its_table_at_a_mapping_that_the_guest_fails_and_stops_the_guest` | A clone's order sends no more of its table, nor `Start`, once the guest fails a mapping, and asks the guest to stop | -| `host_session` | `a_clone_s_order_maps_the_root_of_its_export_with_an_empty_source` | A clone's order sends the mapping of its export's root, whose source is empty, in its table | +| `host_session` | `a_clone_s_order_maps_its_export_s_children_by_their_indices` | A clone's order sends its table as a cold launch's, whose sources name each path by the index of the export's child that holds it | | `host_session` | `a_clone_s_order_sends_its_record_first_then_starts_its_sandbox_as_after_a_cold_boot` | A clone's order sends its activation record first, returns the activation that `Ready` reports, then sends the table in parts and `Start`, and fails on a refusal, a deadline, another build, or an answer that does not decode | | `host_session` | `a_clone_s_session_that_ends_after_its_bootstrap_is_lost_and_the_next_takes_the_same_record` | A clone's session that a reset, the end of its stream, or its transport ended is lost, and the next session sends the same record first | | `host_session` | `a_mapping_that_the_guest_fails_ends_the_table_and_the_host_stops_the_guest` | A mapping that the guest fails, at the first, a middle, or the last part, ends the table without `Start`, and the host stops the guest and learns how the stop ended | diff --git a/tests/runtime-adapter/scenarios.txt b/tests/runtime-adapter/scenarios.txt index fea23052f..56b35bab2 100644 --- a/tests/runtime-adapter/scenarios.txt +++ b/tests/runtime-adapter/scenarios.txt @@ -27,7 +27,7 @@ clone_start the_host_program_refuses_options_that_do_not_make_a_run clone_start the_host_program_refuses_options_that_do_not_make_a_start host_session a_clone_s_order_ends_its_table_at_a_mapping_that_the_guest_fails_and_stops_the_guest -host_session a_clone_s_order_maps_the_root_of_its_export_with_an_empty_source +host_session a_clone_s_order_maps_its_export_s_children_by_their_indices host_session a_clone_s_order_sends_its_record_first_then_starts_its_sandbox_as_after_a_cold_boot host_session a_clone_s_session_that_ends_after_its_bootstrap_is_lost_and_the_next_takes_the_same_record host_session a_mapping_that_the_guest_fails_ends_the_table_and_the_host_stops_the_guest diff --git a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs index 66d60b536..a751a6e29 100644 --- a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs +++ b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs @@ -160,9 +160,9 @@ //! `stage` stages them, its processor target, 1, 2, 4, or 8, from the template's processors //! online at boot to its capacity, which the restore brings online, 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. +//! capacity, and its export, if it has one, which must be one that a cold launch attaches, of +//! directories and files that neither lie inside the template store's directory nor hold it: +//! the clone's restore attaches it to the template's dormant filesystem slot as an aggregate. //! 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`. Once NVX checked the export again, with diff --git a/tests/runtime-adapter/src/clone.rs b/tests/runtime-adapter/src/clone.rs index d44e25edd..d49eca366 100644 --- a/tests/runtime-adapter/src/clone.rs +++ b/tests/runtime-adapter/src/clone.rs @@ -16,8 +16,9 @@ //! 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, takes //! only a network at the guest's address of the template's NIC, whose policy the clone's restore -//! installs on the NIC in place of the template's complete deny-all policy, and only an export of -//! one directory, which the clone's restore binds to the template's dormant filesystem slot. +//! installs on the NIC in place of the template's complete deny-all policy, and only an export +//! that a cold launch attaches, which the clone's restore attaches to the template's dormant +//! filesystem slot as an aggregate. //! //! 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 @@ -129,9 +130,8 @@ impl Request { /// 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. + /// the adapter authorizes. An export that no clone takes, such as one that holds the template + /// store, or lies inside it, is NVX's `ClonePlan` to refuse. pub fn export(&self) -> Result> { self.sharing.plan() } diff --git a/tests/runtime-adapter/tests/host_session.rs b/tests/runtime-adapter/tests/host_session.rs index 213388e6a..e52e2a68f 100644 --- a/tests/runtime-adapter/tests/host_session.rs +++ b/tests/runtime-adapter/tests/host_session.rs @@ -1995,12 +1995,14 @@ fn a_clone_s_order_ends_its_table_at_a_mapping_that_the_guest_fails_and_stops_th } #[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. +fn a_clone_s_order_maps_its_export_s_children_by_their_indices() { + // A clone's table is a cold launch's: its sources name each path by the index of the export's + // child that holds it, 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), + mapping("0", "/data", true), + mapping("1", "/notes.txt", false), + mapping("0/work", "/work", false), ]; let parts = encode_maps(&table).unwrap(); assert_eq!(parts.len(), 1); @@ -2013,8 +2015,9 @@ fn a_clone_s_order_maps_the_root_of_its_export_with_an_empty_source() { .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), + (b"0".as_slice(), b"/data".as_slice(), true), + (b"1".as_slice(), b"/notes.txt".as_slice(), false), + (b"0/work".as_slice(), b"/work".as_slice(), false), ] ); let (mut broker, client) = attached(FIRST, usize::MAX); From 994ea7bb759b2ffd26e1a5a646e62b5e578fb9c1 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Wed, 28 Oct 2026 18:50:36 -0700 Subject: [PATCH 3/3] runtime-adapter: restore clones with aggregate exports, in VMs A clone's VM scenario now restores a template with an aggregate of host directories and files (#857): a read-only directory with a read-write one inside it, a read-only file, a read-write file, and a read-write directory, with a hidden file, and a hidden directory that shows a file again, which the start shares with --allow. On Linux, the scenarios check that the clone's VMM attaches its export as a cold launch attaches one, with --mount-aggregate and a --mount-child of each directory and file, and that a clone without an export passes none of OpenVMM's options of an export, which leaves the template's filesystem slot dormant. A new scenario changes a mapped object once NVX's plan checked the clone, while the step hook pauses the start at checked: a directory replaces a read-only file, or another directory a pinned one inside a read-write directory. The clone's launch checks the export again, and refuses it, before any VMM starts. The scenario of stage 1's interim rule, which refused an export of two directories, or of a file alone, goes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: afd23653-4313-4e60-ab6b-350dffc1890b --- tests/runtime-adapter/README.md | 12 +- tests/runtime-adapter/scenarios.txt | 4 +- tests/runtime-adapter/tests/step_hook.rs | 2 +- tests/runtime-adapter/tests/vm_clone.rs | 397 ++++++++++++++++------- 4 files changed, 282 insertions(+), 133 deletions(-) diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index 4d9c13ede..106033724 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -2903,16 +2903,16 @@ directory that the starts authorize: | `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 whose selection the store refuses, with status 10 and the record of an `ineligible` generation, by the evidence of its tombstone, 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_maps_an_aggregate_of_directories_and_files_beneath_its_root_before_its_sandbox_starts` | Over a scratch image, sharing directories and files of a host directory: a read-only directory, with a read-write directory inside it, at its target there, a read-only file, a read-write file, and a read-write directory, with a file of the first hidden, and of the last a directory hidden, but a file in it shown again | Started, with five mappings, and a record that names the export, an aggregate, by the identity of its plan, with five mappings; on Linux, the VMM's `--mount-aggregate` and one `--mount-child` per directory and file, in order, the first directory read-write, each file with its access and `file`, then the hidden paths, the one shown again, and the one that the guest may write, and no `--mount`; 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 files, and the hidden directory, which lists the file shown again alone, 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, an aggregate, by the identity of its plan, the same for the clones of one export, and on Linux, the VMM's `--mount-aggregate`, with one `--mount-child`, 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, none of OpenVMM's options of an export 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_whose_mapped_objects_changed_before_any_vmm_starts` | Sharing a read-write directory, a directory inside it at a target of its own, and a read-only file; while the step hook pauses each start at `checked`, a directory replaces the file, then another directory the one inside the read-write directory | Status 8, with NVX's reason, and the record of a failure of the request, at the launch, without a VM, by the evidence of invalid mappings, which quarantines nothing; no VMM's log, launch journal, runtime record, or record of the clone's launch in the sandbox, and the lease released | | `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_template_without_a_nic_serves_clones_without_a_network` | Two at once, without a network, and with one that lets nothing through; then, once they stopped, one of each network with a NIC of the template scenarios | The first two started, with no network in their records, on Linux no argument of a network of their VMMs, no network device, and the image's own `/etc/resolv.conf`, an empty file of mode 0644, which no bind holds, and root may write; then for each of the others, 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 settings, which quarantines nothing; the lease released | | `one_template_serves_the_clones_of_every_network_policy_at_its_guest_s_address` | Of a template of host-loopback access both ways, with a forward: in turn, one of each network with a NIC, of egress allowed, with outbound host-loopback access or without, of one TCP rule to the gateway, with outbound host-loopback access, of a proxy, of ingress allowed, of host-loopback access both ways, and of that access with a forward; then one at the guest's address `192.0.2.3/24`, one of a network that lets nothing through, and one without a network | Each of the first started, with one network device, on Linux the VMM's `--restore-network-policy`, then the arguments of the clone's own policy, without `--net`, and a record that names the policy, the gateway's DNS server for the networks with egress allowed, the proxy's URL for the proxy-only one, and a publication of the guest's listeners for those with ingress or host-loopback access both ways; an execution's TCP connections to the gateway, at the ports of the host's listeners on host loopback, that reach the allow rule's alone, the proxy's alone, every one with egress and outbound host-loopback access, and none otherwise, as the host's listeners confirm; a listener of the guest, as nobody, that the host reaches at its port of host loopback, with a line each way, exactly with host-loopback access both ways, once the clone's announcer announced it, and through the forward's port; and executions as user ID 0 that ask for `CAP_NET_BIND_SERVICE` or `CAP_NET_RAW` refused with `EPERM` where the listeners are published, and started elsewhere, as one that asks for `CAP_CHOWN` everywhere; then for each of the others, 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 settings, which quarantines nothing; the lease released; each clone's policy differs from the others' | | `a_forward_whose_port_another_socket_holds_fails_the_clone_as_the_host_s` | Of a template of a network with egress allowed, one of host-loopback access both ways, with a forward of a port of host loopback that a listener of the host holds; then, once the listener closed, one of the same network | Status 8, with OpenVMM's cause, that it cannot create the forward's socket, and the record of a failure of the host, at the restore, once the VMM exited before its restore-ready event, by the evidence of the forward's bind, which quarantines nothing; the lease released, with the records; then a clone that starts and stops | | `a_clone_binds_the_resolver_of_its_policy_s_dns_server_read_only` | Of a template of a proxy-only network, two at once, of a network with egress allowed, with a generated machine ID, and with the image's own; then, once they stopped, a GPT disk whose layer holds a regular file `etc`, with the image's own machine ID | Each of the first two started, with a record that names the gateway's DNS server, one network device, and at `/etc/resolv.conf`, a file of mode 0444 that names the server, held by a bind that is read-only, `nosuid`, and `nodev`, after the bind of the generated machine ID, below the root, whose file system refuses a write as root, with nothing in the upper there; and a TCP connection to the server's DNS port, which the gateway's DNS proxy accepts without the host's resolver; then status 8, since no bind can hold the resolver configuration below a file, and the record of a failure of the request, at the activation, once the VM exited, by the evidence of invalid settings, with the guest's status 57; nothing quarantined, and the lease released, with the records | | `a_clone_s_executions_find_the_proxy_of_its_policy` | Of a template of a network with egress allowed, one of a proxy-only network, with an execution as user ID 0 that runs `env`, then one that names its own `HTTPS_PROXY` | A record that names the proxy's URL, `http://192.0.2.1:PORT`, at the port of the proxy's listener, and no DNS server; the first execution with its own entry, then the URL in `http_proxy`, `https_proxy`, `HTTP_PROXY`, and `HTTPS_PROXY`; the second with exactly its own environment; one network device, and the image's own `/etc/resolv.conf`, which no bind holds | -| `a_clone_of_a_template_with_a_nic_maps_its_export_beside_its_resolver` | Of a template of a network with egress allowed, one with a generated machine ID, sharing a host directory read-write | Started, with one mapping, and a record that names the export and the gateway's DNS server; on Linux, the VMM's one `--mount`, read-write, and its `--restore-network-policy`, then the arguments of the clone's policy; below the root, the binds of the machine ID and of the resolver configuration, then the export's mapping; the export's directory, and a write through it, which the host's directory holds after the stop | +| `a_clone_of_a_template_with_a_nic_maps_its_export_beside_its_resolver` | Of a template of a network with egress allowed, one with a generated machine ID, sharing a host directory read-write | Started, with one mapping, and a record that names the export and the gateway's DNS server; on Linux, the VMM's `--mount-aggregate`, with one `--mount-child`, read-write, and its `--restore-network-policy`, then the arguments of the clone's policy; below the root, the binds of the machine ID and of the resolver configuration, then the export's mapping; the export's directory, and a write through it, which the host's directory holds after the stop | | `a_selection_that_names_no_template_leases_nothing` | A start that names a template store that does not exist, then, once `remove` retired the generation, which no lease kept, a start of it | Status 10, with the record of a selection that names no template, by the evidence `no-store`, then `retired`, without a lease or a VM; and a store that indexes no generation | | `a_run_runs_one_execution_in_its_clone_then_tears_the_clone_down` | A run over a scratch image, as nobody, whose execution names its environment and its working directory, and ends with status 3, then a run in RAM, as nobody, whose execution names an identity of its own | Each with status 0, in one command: the preflight's `staged`, the clone started as a start starts one, the execution, and a stop that releases the lease, without a VM; the first execution's output, of the Ubuntu image's root, as nobody, in its working directory, with its environment, and its status; the second refused with `EINVAL`; no lease left, and the payload with nothing but the capture's files | | `a_run_whose_clone_does_not_start_runs_nothing` | A run that names a template store that does not exist, one with a clock fixture past the restore window, and one over a scratch image of zeros | Once the preflight staged the images, status 10, with the record of a selection that names no template, by the evidence `no-store`, then of an `expired` generation, without a lease or a VM; then status 8, with the record of a failure of the request at the activation, once the VM exited, the lease released, and no execution or stop | @@ -3079,7 +3079,7 @@ layered roots, captures of templates, and the starts and runs of their clones. | `vm_template` | `a_template_without_a_network_has_no_nic` | `create` captures a template without a NIC, whose key holds what it asked for, its processor online at boot among it, and serves it again without a capture | | `vm_template` | `the_networks_of_every_policy_at_one_guest_s_address_share_one_template` | `create` of a network of any policy captures one template, whose NIC the capture attaches under the complete deny-all policy, and the creations of the networks of every other policy at the same guest's address serve it without a capture | | `vm_clone` | `a_clone_binds_the_resolver_of_its_policy_s_dns_server_read_only` | A clone of a network with egress allowed binds a resolver file that names the gateway's DNS server read-only at `/etc/resolv.conf`, and a TCP connection to the server's DNS port opens, which the gateway's DNS proxy accepts; one whose resolver cannot be bound ends with status 57 | -| `vm_clone` | `a_clone_maps_its_export_beneath_its_root_before_its_sandbox_starts` | A clone maps its export's mappings beneath its root before its sandbox starts, each with its access, a denied file hidden, and writes reach the host's directory through the writable ones alone | +| `vm_clone` | `a_clone_maps_an_aggregate_of_directories_and_files_beneath_its_root_before_its_sandbox_starts` | A clone maps an aggregate of host directories and files beneath its root before its sandbox starts, each with its access, a hidden file, and a hidden directory that shows a file again, and writes reach the host's directories and file through the writable ones alone | | `vm_clone` | `a_clone_of_a_template_with_a_nic_maps_its_export_beside_its_resolver` | A clone of a template with a NIC maps its export below the binds of its machine ID and of its resolver configuration | | `vm_clone` | `a_clone_reads_no_more_of_its_disks_than_its_root_needs` | A clone's restore reads less than an eighth of each disk, before the guest reads each to its end | | `vm_clone` | `a_clone_refuses_a_scratch_image_whose_upper_an_earlier_clone_used` | A scratch image whose upper an earlier clone used ends the clone before it is ready, with status 54 | @@ -3100,7 +3100,7 @@ layered roots, captures of templates, and the starts and runs of their clones. | `vm_clone` | `a_run_whose_clone_does_not_start_runs_nothing` | A run whose selection the store refuses ends with status 10, and one whose clone fails with status 8, without an execution or a stop | | `vm_clone` | `a_run_whose_clone_s_vm_exits_during_its_execution_ends_with_the_clone_s_failure` | A run whose clone's VM exits during its execution ends with the clone's failure, status 8, once its stop found that the VM exited, quarantines nothing, and releases its lease | | `vm_clone` | `a_selection_that_names_no_template_leases_nothing` | A start of a template store that does not exist, or of a generation that `remove` retired, ends with status 10 and the refusal's record, without a lease or a VM | -| `vm_clone` | `a_start_refuses_an_export_that_a_dormant_filesystem_slot_cannot_bind_before_any_vmm_starts` | An export of two directories, or of a file alone, is refused with status 8 before any VMM starts | +| `vm_clone` | `a_start_refuses_an_export_whose_mapped_objects_changed_before_any_vmm_starts` | An export whose mapped file, or pinned directory, another object replaced once NVX's plan checked the clone is refused with status 8 at the clone's launch, before any VMM starts | | `vm_clone` | `a_start_refuses_what_the_template_cannot_serve_before_any_vmm_starts` | Images, scratch images, and memory targets that the template cannot serve are refused with status 8 before any VMM starts, unstaged images with status 1, and a generation that the store does not hold with status 10 | | `vm_clone` | `a_template_without_a_nic_serves_clones_without_a_network` | A template without a NIC serves clones without a network, or with one that lets nothing through, and refuses those of a network with a NIC with status 8 | | `vm_clone` | `a_tombstone_leaves_the_running_clone_of_its_generation_to_its_lease` | A tombstone refuses new clones of its generation with status 10, while the running clone serves on, and releases its lease once it stops | diff --git a/tests/runtime-adapter/scenarios.txt b/tests/runtime-adapter/scenarios.txt index 56b35bab2..88b9f3aac 100644 --- a/tests/runtime-adapter/scenarios.txt +++ b/tests/runtime-adapter/scenarios.txt @@ -137,7 +137,7 @@ vm_template a_template_without_a_network_has_no_nic vm_template the_networks_of_every_policy_at_one_guest_s_address_share_one_template vm_clone a_clone_binds_the_resolver_of_its_policy_s_dns_server_read_only -vm_clone a_clone_maps_its_export_beneath_its_root_before_its_sandbox_starts +vm_clone a_clone_maps_an_aggregate_of_directories_and_files_beneath_its_root_before_its_sandbox_starts vm_clone a_clone_of_a_template_with_a_nic_maps_its_export_beside_its_resolver vm_clone a_clone_reads_no_more_of_its_disks_than_its_root_needs vm_clone a_clone_refuses_a_scratch_image_whose_upper_an_earlier_clone_used @@ -158,7 +158,7 @@ vm_clone a_run_s_execution_finds_its_shares_beneath_the_root vm_clone a_run_whose_clone_does_not_start_runs_nothing vm_clone a_run_whose_clone_s_vm_exits_during_its_execution_ends_with_the_clone_s_failure vm_clone a_selection_that_names_no_template_leases_nothing -vm_clone a_start_refuses_an_export_that_a_dormant_filesystem_slot_cannot_bind_before_any_vmm_starts +vm_clone a_start_refuses_an_export_whose_mapped_objects_changed_before_any_vmm_starts vm_clone a_start_refuses_what_the_template_cannot_serve_before_any_vmm_starts vm_clone a_template_without_a_nic_serves_clones_without_a_network vm_clone a_tombstone_leaves_the_running_clone_of_its_generation_to_its_lease diff --git a/tests/runtime-adapter/tests/step_hook.rs b/tests/runtime-adapter/tests/step_hook.rs index 180d6069c..e3d669f33 100644 --- a/tests/runtime-adapter/tests/step_hook.rs +++ b/tests/runtime-adapter/tests/step_hook.rs @@ -2,7 +2,7 @@ //! and those that the host program refuses, a start's, which it takes at `checked` alone, and a //! pause, which waits until its file is gone, on Windows until a pending deletion of the file //! completes, and says how long it paused. The tests need no hypervisor; the VM tests pause -//! launches and stops with the hook, and end them. +//! launches, starts, and stops with the hook, and end them. #![cfg(feature = "host")] use std::ffi::OsStr; diff --git a/tests/runtime-adapter/tests/vm_clone.rs b/tests/runtime-adapter/tests/vm_clone.rs index c8133457f..0de08ba29 100644 --- a/tests/runtime-adapter/tests/vm_clone.rs +++ b/tests/runtime-adapter/tests/vm_clone.rs @@ -60,20 +60,23 @@ //! - 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. +//! - A clone maps its export beneath its root once it is ready, before its sandbox starts: an +//! aggregate of host directories and files, read-only and read-write, which NVX attaches to the +//! template's dormant filesystem slot as a cold launch attaches one, each directory and file a +//! child of its own, and whose mappings, each from a source that names its child by its index, +//! keep their access, show what the host holds, but for the paths that the start hides, and +//! those inside them that it shows again, and take what the guest, and the clone's workload, +//! write through the writable ones alone. The clone's record names the export, an aggregate, 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 start refuses an export whose mapped objects changed once NVX's plan checked the clone +//! before any VMM starts, as a failure of the request, which the clone's launch finds as it +//! checks the export again: a file that a directory replaced, and a directory, inside one that +//! the guest may write, that another directory replaced. //! - 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 @@ -199,7 +202,6 @@ use nvx::activation::{ }; 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}; @@ -285,59 +287,92 @@ 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] = [ +/// The directories of the host directory whose directories and files a clone exports as an +/// aggregate, with the [`EXPORT_SHARES`]: `dir`, which holds the empty directory `inner`, and +/// `out`, which holds the directory `private`. +const EXPORT_DIRECTORIES: [&str; 4] = ["dir", "dir/inner", "out", "out/private"]; +/// The files of that host directory, each a path in it, with components separated by `/`, and +/// its contents. +const EXPORT_FILES: [(&str, &str); 7] = [ ("dir/marker", "marker"), ("dir/secret", "secret"), ("file.txt", "file"), + ("notes.txt", "notes"), ("out/marker", "marker"), + ("out/private/key", "key"), + ("out/private/shown", "shown"), ]; -/// 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"), +/// The shares of a clone that exports an aggregate, each a path in the host directory, and its +/// target in the root, with `:ro` for a read-only share: the directory `dir`, read-only, with the +/// read-write directory `dir/inner` inside it, at the target that it has there, the read-only file +/// `file.txt`, the read-write file `notes.txt`, and the read-write directory `out`. The plan +/// exports the outermost shares, `dir`, `file.txt`, `notes.txt`, and `out`, as the aggregate's +/// children 0 to 3, and the start hides the [`EXPORT_DENIED`] paths, but for the +/// [`EXPORT_ALLOWED`] one. +const EXPORT_SHARES: [(&str, &str); 5] = [ + ("dir", "/srv/maps/dir:ro"), ("dir/inner", "/srv/maps/dir/inner"), - ("file.txt", "/srv/file.txt:ro"), + ("file.txt", "/srv/maps/file.txt:ro"), + ("notes.txt", "/srv/notes.txt"), ("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 paths of the host directory of the [`EXPORT_SHARES`] that the start hides: the file +/// `dir/secret`, and the directory `out/private`. +const EXPORT_DENIED: [&str; 2] = ["dir/secret", "out/private"]; +/// The path inside a hidden one of the [`EXPORT_DENIED`] that the start shows again, which the +/// directory `out/private` lists alone. +const EXPORT_ALLOWED: [&str; 1] = ["out/private/shown"]; +/// The `map` probe of the [`EXPORT_SHARES`]: each target, the hidden files, and the file that the +/// start shows again. +const MAP_EXPORT: &str = "map dir=srv/maps/dir inner=srv/maps/dir/inner file=srv/maps/file.txt \ + notes=srv/notes.txt out=srv/out secret=srv/maps/dir/secret \ + key=srv/out/private/key shown=srv/out/private/shown"; /// 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"; +const EXPORT_MOUNTS: &str = + "/srv/notes.txt,/srv/out,/srv/maps/dir,/srv/maps/file.txt,/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 +/// [`EXPORT_SHARES`], `/bin/sh -c EXPORTED sh`, which prints, a line each: the contents of each +/// file that a mapping shows, `read PATH TEXT`, or `read PATH refused` for one that cannot be +/// read; whether each hidden file could be read, `hidden PATH read`, or not, `hidden PATH +/// refused`; the names in `/srv/maps/dir`, `/srv/out`, and `/srv/out/private`, each followed by a +/// comma, `listing DIRECTORY NAMES`; for each of `/srv/maps/dir`, `/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`; and for each of `/srv/maps/file.txt` and +/// `/srv/notes.txt`, whether an append of ` workload` to it succeeded, `append FILE written`, or +/// not, `append FILE refused`. +const EXPORTED: &str = r#"for path in /srv/maps/dir/marker /srv/maps/file.txt /srv/notes.txt \ + /srv/out/private/shown; 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 +for path in /srv/maps/dir/secret /srv/out/private/key; do + if cat "$path" >/dev/null 2>&1; then + echo "hidden $path read" + else + echo "hidden $path refused" + fi +done +for directory in /srv/maps/dir /srv/out /srv/out/private; do + echo "listing $directory $(ls "$directory" | tr '\n' ',')" +done +for directory in /srv/maps/dir /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 +for file in /srv/maps/file.txt /srv/notes.txt; do + if { printf '%s' ' workload' >> "$file"; } 2>/dev/null; then + echo "append $file written" + else + echo "append $file refused" + fi +done "#; /// The `map` probe of the clones that share one host directory at `/srv/data`: the directory, and /// its file `marker`. @@ -1068,6 +1103,7 @@ impl Template { /// preflight stages the clone's images. fn run(&self, name: &str, start: StartOf<'_>, execution: &[&str], status: i32) -> Cloned { assert!(!start.unstaged, "a run stages its clone's images"); + assert!(start.at_checked.is_none(), "a run takes no step hook"); self.clone_in(name, "run", &start, execution, status) } @@ -1094,7 +1130,9 @@ impl Template { /// Begins the host program's `command`, `start` or `run`, of a clone in a new sandbox of the /// scenario `name`, as `start` asks, with the arguments `execution` after the start's options, /// and returns the sandbox, with the command, which may still run. Before a start, the - /// sandbox's store stages the clone's images, unless `start` says otherwise. + /// sandbox's store stages the clone's images, unless `start` says otherwise. A start that + /// `start` asks to act at `checked` pauses there, through the step hook, until the scenario + /// acted. fn begin_in( &self, name: &str, @@ -1165,11 +1203,25 @@ impl Template { .time .as_ref() .map(|time| OsString::from(host_time_text(time))); + let pause = sandbox.root.join("checked.pause"); + let hook = start.at_checked.map(|_| { + let hook = StepHook { + step: Step::Checked, + action: Action::Pause(pause.clone()), + }; + OsString::from(hook.to_string()) + }); let environment: Vec<(&str, &OsStr)> = time .iter() .map(|time| (HOST_TIME_VARIABLE, time.as_os_str())) + .chain(hook.iter().map(|hook| (STEP_HOOK, hook.as_os_str()))) .collect(); - let started = sandbox.start(command, &options, &environment); + let mut started = sandbox.start(command, &options, &environment); + if let Some(act) = start.at_checked { + assert_eq!(sandbox.paused(&mut started, &pause), "checked"); + act(); + fs::remove_file(&pause).unwrap(); + } (sandbox, started) } } @@ -1251,6 +1303,9 @@ struct StartOf<'a> { time: Option, /// Whether the sandbox's store stages no image. unstaged: bool, + /// What the scenario does while the step hook pauses the start at `checked`, once NVX's plan + /// checked the clone, if anything. + at_checked: Option<&'a dyn Fn()>, } /// A clone of a scenario's template, in a sandbox of its own, as its start, or its run, left it. @@ -1575,34 +1630,28 @@ impl Cloned { } /// 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)>) { + /// root, reads them: of OpenVMM's options of an export, `--mount` and those that start with + /// `--mount-`, exactly `export`, each an option and its value, in order, the options that + /// attach the clone's export to the template's dormant filesystem slot, as a cold launch + /// attaches it, 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_export(&self, export: &[(&str, String)]) { #[cfg(target_os = "linux")] { let arguments = self.vmm_arguments(); - let mounts = arguments + let options = arguments .windows(2) - .filter(|pair| pair[0] == "--mount") - .map(|pair| pair[1].as_str()) + .filter(|pair| pair[0] == "--mount" || pair[0].starts_with("--mount-")) + .map(|pair| (pair[0].as_str(), pair[1].clone())) .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:?}"); + assert_eq!(options, export, "{arguments:?}"); } #[cfg(not(target_os = "linux"))] let _ = export; } /// Checks the arguments with which NVX started the clone's VMM, on Linux, as - /// [`assert_vmm_mount`](Self::assert_vmm_mount) does: `policy`, `--restore-network-policy`, + /// [`assert_vmm_export`](Self::assert_vmm_export) does: `policy`, `--restore-network-policy`, /// then the options of the policy that the clone's restore installs on the template's NIC, in /// order, as NVX lowers the clone's network, and no other argument of a network, so no /// `--net`, since OpenVMM takes the NIC and the guest's address from the snapshot, nor any @@ -1634,7 +1683,7 @@ impl Cloned { } /// Checks the arguments with which NVX started the clone's VMM, on Linux, as - /// [`assert_vmm_mount`](Self::assert_vmm_mount) does, for a clone over `upper`: the image as + /// [`assert_vmm_export`](Self::assert_vmm_export) does, for a clone over `upper`: the image as /// the `distro` sandbox block device, then, over a scratch image, the image as the `scratch` /// one, or, in RAM, none, but `--restore-absent-scratch`, which declares the template's /// scratch absent, so that OpenVMM attaches it with no file. @@ -1784,6 +1833,27 @@ impl Export { .fold(self.root.clone(), |host, name| host.join(name)) } + /// Returns the host path of `path`, a path in the directory, as an export's plan resolves it. + fn canonical(&self, path: &str) -> String { + fs::canonicalize(self.path(path)) + .unwrap() + .display() + .to_string() + } + + /// Returns OpenVMM's options, each with its value, that attach an aggregate of `children`, in + /// order, each a path in the directory, `.` for the directory itself, and its access, `ro` or + /// `rw`, with `,file` for a file, as a cold launch attaches it, but for the paths that it + /// hides, shows again, or lets the guest write, which follow them. + fn attached(&self, children: &[(&str, &str)]) -> Vec<(&'static str, String)> { + let mut options = vec![("--mount-aggregate", GUEST_EXPORT.to_owned())]; + for (index, (path, access)) in children.iter().enumerate() { + let child = format!("{index},{},{access}", self.canonical(path)); + options.push(("--mount-child", child)); + } + options + } + /// 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`]. @@ -3721,10 +3791,25 @@ fn start_in(sandbox: &mut Sandbox, template: &Template, templates: &Path) -> Pri #[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() { +fn a_clone_maps_an_aggregate_of_directories_and_files_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"]); + // A Linux host shows the guest each host object's owner and mode, which the guest's kernel + // checks, so that anyone may write the read-write file. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let permissions = fs::Permissions::from_mode(0o666); + fs::set_permissions(export.path("notes.txt"), permissions).unwrap(); + } + let mut shares = export.options(&EXPORT_SHARES, &EXPORT_DENIED); + for path in EXPORT_ALLOWED { + shares.extend([ + "--allow".to_owned(), + export.path(path).display().to_string(), + ]); + } let options = shares.iter().map(String::as_str).collect::>(); let start = StartOf { upper: Upper::Scratch, @@ -3734,22 +3819,40 @@ fn a_clone_maps_its_export_beneath_its_root_before_its_sandbox_starts() { 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. + // The clone's record names the export, an aggregate, by the identity of its plan, and the + // number of its mappings, which the restore sequence sent once the clone was ready. NVX + // attached the aggregate to the template's dormant filesystem slot as a cold launch attaches + // one: its children, the outermost shares, in order, `dir` read-write, since the guest may + // write `dir/inner` in it, and nothing else of it, each file with its access, and `out`, then + // the paths that the start hides, the one that it shows again, and the one that the guest may + // write in `dir`. let configuration = clone.configuration(); let recorded = configuration.export.expect("the record names the export"); - assert_eq!(recorded.kind, ExportKind::Directory); + assert_eq!(recorded.kind, ExportKind::Aggregate); assert_eq!(configuration.mappings, EXPORT_SHARES.len()); - clone.assert_vmm_mount(Some((&export, "rw"))); + let mut attached = export.attached(&[ + ("dir", "rw"), + ("file.txt", "ro,file"), + ("notes.txt", "rw,file"), + ("out", "rw"), + ]); + for (option, path) in [ + ("--mount-deny", "dir/secret"), + ("--mount-deny", "out/private"), + ("--mount-allow", "out/private/shown"), + ("--mount-write", "dir/inner"), + ] { + attached.push((option, export.canonical(path))); + } + clone.assert_vmm_export(&attached); // 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. + // included, each showing what the host holds, but the files that the start hides, and the + // directory that holds the one that it shows again, which lists it alone. 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!( @@ -3764,9 +3867,10 @@ fn a_clone_maps_its_export_beneath_its_root_before_its_sandbox_starts() { 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"), + ("dir", "ro", "directory", "refused", "directory"), ("inner", "rw", "directory", "allowed", "absent"), ("file", "ro", "file 4", "refused", "file 0"), + ("notes", "rw", "file 5", "allowed", "file 0"), ("out", "rw", "directory", "allowed", "directory"), ] { let options = mount_options(fact(&format!("{name}.options"))); @@ -3776,26 +3880,39 @@ fn a_clone_maps_its_export_beneath_its_root_before_its_sandbox_starts() { 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("dir.entries"), "inner,marker"); 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("notes.read"), "notes"); + assert_eq!(fact("out.entries"), "marker,private"); + for name in ["secret", "key"] { + let hidden = ["options", "kind", "upper"].map(|key| fact(&format!("{name}.{key}"))); + assert_eq!(hidden, ["none", "denied", "absent"], "{name}"); + } + let shown = + ["options", "kind", "read", "write", "upper"].map(|key| fact(&format!("shown.{key}"))); + assert_eq!(shown, ["none", "file 5", "shown", "allowed", "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. + // not the hidden ones, 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", + "read /srv/maps/file.txt file", + "read /srv/notes.txt notes", + "read /srv/out/private/shown shown", + "hidden /srv/maps/dir/secret refused", + "hidden /srv/out/private/key refused", + "listing /srv/maps/dir inner,marker,", + "listing /srv/out marker,private,written,", + "listing /srv/out/private shown,", + "write /srv/maps/dir refused", "write /srv/maps/dir/inner written", "write /srv/out written", + "append /srv/maps/file.txt refused", + "append /srv/notes.txt written", ] ); @@ -3805,6 +3922,7 @@ fn a_clone_maps_its_export_beneath_its_root_before_its_sandbox_starts() { for (file, contents) in [ ("dir/inner/written", "runtime-adapter"), ("dir/inner/workload", "workload"), + ("notes.txt", "notes workload"), ("out/written", "runtime-adapter"), ("out/workload", "workload"), ] { @@ -3863,10 +3981,11 @@ fn clones_at_once_see_only_their_own_exports() { } 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. + // Each record names its clone's export, an aggregate of one 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 none of OpenVMM's options of an + // export, which leaves the template's filesystem slot dormant. NVX attached each directory to + // the slot as its aggregate's one child, with the access that its mapping needs. let exported = |clone: &Cloned| { let configuration = clone.configuration(); let export = configuration @@ -3877,18 +3996,18 @@ fn clones_at_once_see_only_their_own_exports() { 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!((own_export.0, own_mappings), (ExportKind::Aggregate, 1)); assert_eq!( (common_export.0, common_mappings), - (ExportKind::Directory, 1) + (ExportKind::Aggregate, 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); + with_own.assert_vmm_export(&own.attached(&[(".", "rw")])); + with_common.assert_vmm_export(&common.attached(&[(".", "ro")])); + with_same.assert_vmm_export(&common.attached(&[(".", "ro")])); + without.assert_vmm_export(&[]); // 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 @@ -3956,42 +4075,55 @@ fn clones_at_once_see_only_their_own_exports() { #[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 ")), +fn a_start_refuses_an_export_whose_mapped_objects_changed_before_any_vmm_starts() { + let template = Template::create("cl-changed"); + let files = [("data/sub/marker", "sub"), ("notes.txt", "notes")]; + let export = Export::write(&template, "changed", &files, &["data/sub"]); + // A read-write directory, a directory inside it, at a target of its own, which the guest + // could move, so that the export's plan pins it, and a read-only file. + let shares = export.options( + &[ + ("data", "/srv/data"), + ("data/sub", "/srv/sub"), + ("notes.txt", "/srv/notes.txt:ro"), + ], + &[], + ); + let options = shares.iter().map(String::as_str).collect::>(); + let (notes, sub) = (export.path("notes.txt"), export.path("data/sub")); + let moved = |path: &Path| path.with_extension("moved"); + // Once NVX's plan checked the clone, a directory replaces the file, which would grant all of + // itself, or another directory the one inside the read-write directory, which would map in + // its place. + let replace_file = || { + fs::rename(¬es, moved(¬es)).unwrap(); + fs::create_dir(¬es).unwrap(); + }; + let replace_directory = || { + fs::rename(&sub, moved(&sub)).unwrap(); + fs::create_dir(&sub).unwrap(); + }; + for (name, replace, replaced) in [ + ("cl-file", &replace_file as &dyn Fn(), ¬es), + ("cl-directory", &replace_directory, &sub), ] { - let options = options.iter().map(String::as_str).collect::>(); let start = StartOf { options: &options, + at_checked: Some(replace), ..StartOf::default() }; let refused = template.start(name, start, CLONE_FAILED); - refused.assert_refused(&problem); + + // The clone's launch checked the export again, before it wrote anything, and refused it: + // a failure of the request, by the evidence of its mappings, which quarantines nothing, + // with no VM. The start released the lease, and nothing of the clone's launch remains. let printed = &refused.started; assert_eq!( failure(printed), [ "request", "invalid-mappings", - "plan", + "launch", "planned", "none", "no-vm" @@ -3999,10 +4131,27 @@ fn a_start_refuses_an_export_that_a_dormant_filesystem_slot_cannot_bind_before_a "{printed:?}" ); assert_eq!(printed["quarantined"], "no", "{printed:?}"); + assert!(!printed.contains_key("pid"), "{printed:?}"); + assert_eq!(printed["released"], printed["lease"], "{printed:?}"); + let stderr = refused.sandbox.stderr(); + assert!( + stderr.contains("no longer names the host object"), + "{stderr}" + ); + let store = SandboxStore::open(&refused.sandbox.store).unwrap(); + assert_eq!(store.runtime().unwrap(), None); + assert!(store.journal().unwrap().is_none()); + assert!(!refused.sandbox.store.join(LOG_NAME).exists()); + assert!(Lease::of(&store).unwrap().is_none()); + assert!(CloneRecords::read(&store).unwrap().is_none()); + + // The scenario puts the original back for the next start. + fs::remove_dir(replaced).unwrap(); + fs::rename(moved(replaced), replaced).unwrap(); } assert!(template.leases().is_empty()); - assert_eq!(one.tree(), tree_of(&[], &[("marker", "one")])); - assert_eq!(two.tree(), tree_of(&[], &[("marker", "two")])); + let directories = ["data", "data/sub"]; + assert_eq!(export.tree(), tree_of(&directories, &files)); assert_payload(&template, false); } @@ -4011,8 +4160,8 @@ fn a_start_refuses_an_export_that_a_dormant_filesystem_slot_cannot_bind_before_a 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 + // The table's second mapping, after that of the export's directory, 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")], @@ -4055,8 +4204,8 @@ fn a_clone_whose_mapping_fails_ends_before_its_sandbox_starts() { "{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. + // The guest applied the mapping of the export's directory, 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 \ @@ -4492,17 +4641,17 @@ fn a_clone_of_a_template_with_a_nic_maps_its_export_beside_its_resolver() { cloned.assert_mapped(1); // The record names the export, with its one mapping, and the network's DNS server. On Linux, - // the restore bound the export's directory to the template's dormant filesystem slot, and - // installed the clone's policy on the template's NIC. + // the restore attached the export's directory to the template's dormant filesystem slot, as + // its aggregate's one child, and installed the clone's policy on the template's NIC. let configuration = cloned.configuration(); let recorded = configuration.export.map(|export| export.kind); assert_eq!( (recorded, configuration.mappings), - (Some(ExportKind::Directory), 1) + (Some(ExportKind::Aggregate), 1) ); let dns = configuration.network.and_then(|network| network.dns); assert_eq!(dns, Network::Egress.dns()); - cloned.assert_vmm_mount(Some((&export, "rw"))); + cloned.assert_vmm_export(&export.attached(&[(".", "rw")])); cloned.assert_vmm_network(&Network::Egress.restored(GUEST_NETWORK)); // Below the root, the binds of the generated machine ID and of the resolver, which the