From e54abbce07627d3c160bc7a7b5d605f78efcdbb0 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sat, 24 Oct 2026 11:17:41 -0700 Subject: [PATCH] tests: run the host's order of a layered root in the adapter's host program The runtime adapter's host program sent the root configuration, took any Ready as the guest's readiness, whatever the guest agent's build, and went on to its exchanges: it shared no host path with the root, sent no mapping table, and never started the sandbox, so the worker's order had no host side, and a guest agent of another build than the host ships went on serving. launch now takes shares: --share HOST:TARGET[:ro], each a host directory or file at a guest path in the root; --deny PATH, host paths hidden inside them; --reserved TARGET, guest trees that the adapter keeps beside the container root's; and --share-root DIR, the host directories that the adapter authorizes. The new sharing module holds them as a HostFsSharing of Shares, neither of which exposes its fields: each field has a getter and a setter, and each of HostFsSharing's lists a method that adds an entry, as its option does. HostFsSharing::plan refuses a share outside the authorized directories, once links are resolved, before it plans the export with nvx::host::filesystem::plan, and checks the export's directories and files again. The launch refuses what it cannot export before it touches the store. LayeredLaunch::share attaches the export, which LayeredLaunch::export returns, and whose table the root configuration announces; LayeredLaunch::prepare checks it with ExportPlan::verify, and OpenVMM's arguments export it, as ExportPlan::openvmm_arguments renders them. Once attached, host::ready sends the root configuration and checks that the Ready names the build that the host ships; a guest agent of another build gets nothing more, and the launch fails, which ends the VM. host::start_sandbox then sends the table, each part once the guest applied the one before, with Client::map, then Start, with Client::start, and waits for Started; the launch commits only after. A part that the guest fails ends the order: the host sends no more of the table, nor Start, but Stop, which tears the root down, then waits for the VM to end, and exits with 6 once it ended with a success and a complete teardown. The order fails with an OrderError, and Client::map answers a part with a MapReply, each in a module of its own below host, which re-exports both. The launch prints build, table, mapped, and sandbox. Part of #822. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 04632931-537b-49f9-acc9-b12024ad6f3a --- tests/runtime-adapter/README.md | 182 +++++-- .../src/bin/runtime-adapter-host.rs | 213 +++++++-- tests/runtime-adapter/src/hook.rs | 17 +- tests/runtime-adapter/src/host.rs | 116 ++++- tests/runtime-adapter/src/host/map_reply.rs | 14 + tests/runtime-adapter/src/host/order_error.rs | 76 +++ tests/runtime-adapter/src/layered.rs | 89 +++- tests/runtime-adapter/src/lib.rs | 23 +- tests/runtime-adapter/src/root.rs | 2 +- tests/runtime-adapter/src/sharing.rs | 279 +++++++++++ tests/runtime-adapter/tests/host_session.rs | 384 ++++++++++++++- tests/runtime-adapter/tests/layered_launch.rs | 445 +++++++++++++++++- tests/runtime-adapter/tests/vm_layered.rs | 7 + tests/runtime-adapter/tests/vm_restart.rs | 89 +++- 14 files changed, 1804 insertions(+), 132 deletions(-) create mode 100644 tests/runtime-adapter/src/host/map_reply.rs create mode 100644 tests/runtime-adapter/src/host/order_error.rs create mode 100644 tests/runtime-adapter/src/sharing.rs diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index fdb2d0e45..cabb180d2 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -22,7 +22,10 @@ stages and plans with NVX's typed plan for an adapter's own guest, whose staged bindings a launch checks before it hands OpenVMM the checked files, and which the guest agent's worker assembles through `nvx::guest::root`, in a process of its own rather than PID 1, as the host program's root configuration -asks. The host program then checks the root from inside the VM with the +asks. The host program may share host directories and files with the root, +which it exports with NVX's `filesystem::plan`, and which the worker maps +beneath the root from the host's mapping table, before the host starts the +sandbox. The host program then checks the root from inside the VM with the worker's probes, in a [table of scenarios](#layered-scenarios), to which each new check adds a row. @@ -33,7 +36,7 @@ Each configuration enables one side of `nvx`: | Configuration | Cargo options | `nvx` features | Modules | Binary | | --- | --- | --- | --- | --- | | Protocol-only | none | none | `frame`, `root`, `probe`, `exec` | none | -| Host-only | `--features host` | `host` | `frame`, `root`, `probe`, `exec`, `host`, `runtime`, `initramfs`, `staged`, `layered`, `hook` | `runtime-adapter-host` | +| Host-only | `--features host` | `host` | `frame`, `root`, `probe`, `exec`, `host`, `runtime`, `initramfs`, `staged`, `sharing`, `layered`, `hook` | `runtime-adapter-host` | | Guest-only | `--features guest --target x86_64-unknown-linux-musl` | `guest` | `frame`, `root`, `probe`, `exec`, `guest`, `worker` | `runtime-adapter-guest`, a static executable | ## Framing @@ -144,7 +147,8 @@ root, carries its readiness, `root::Ready`: the length of the guest agent's build, one byte, then the build, 1 to 255 printable ASCII characters without a space. The guest agent's build is the crate's version, `GUEST_BUILD`, which is also the build that the host program ships unless it names another; see -[The runtime identity](#the-runtime-identity). +[The runtime identity](#the-runtime-identity). The host sends a guest agent of +another build nothing more; see [The host's order](#the-hosts-order). The host's mapping table, of as many mappings as the root configuration announces, travels in parts, each in a `Maps`, whose body is a `MAPS` payload @@ -580,6 +584,10 @@ transport to the session's host endpoint, such as the one that - `configure_root` sends a root configuration, and returns how the guest answered: `Ready`, with the guest agent's build, or the stage of the root that failed; +- `map` sends a part of the host's mapping table, and returns how the guest + answered: `Mapped`, or `MapFailed`, with how many of the table's mappings it + applied and the problem; `start` sends `Start`, and checks that the guest + answers `Started`; - `probe` sends a probe, and returns the facts that it found, or the problem. Besides the session's own buffers, the client holds at most the rest of one of @@ -594,6 +602,30 @@ doesn't: it keeps what arrived. A request ends the client on any failure after the framing accepted it, a timeout included, because an answer that arrived later would answer the wrong request. +### The host's order + +A VM with a layered root is ready for workloads once its guest assembled the +root, and applied the host's mapping table. The `host` module runs that order +over a client in two steps, between which the host program releases the files +that it handed OpenVMM, and its [step hook](#the-step-hook) may pause it: + +1. `host::ready` sends the root configuration, which announces how many + mappings the table holds, and waits for `Ready`, whose build must be the + one that the host ships. The host sends a guest agent of another build + nothing more, since it cannot tell what that build would do with it. +2. `host::start_sandbox` sends the table in the parts that `root::encode_maps` + splits it into, each once the guest applied the one before, then `Start`, + and waits for `Started`. A table of no mapping sends no part. + +A part that the guest fails ends the order: the host sends no more of the +table, nor `Start`, so the guest starts no workload on a partial table, and +sends `Stop`, which tears the root down, mappings first, as NVX's managed +lifecycle stops a guest whose mappings failed. The order fails with an +`host::OrderError`: the stage of the root that failed, another build, the +mapping that failed with how the stop ended, a table that does not encode, +which fails before anything is sent, or a failure of the client. The host +program [tears the VM down](#commands) after any of them. + ## VMs The host program runs the guest agent in VMs of NVX's artifacts, through NVX's @@ -693,8 +725,9 @@ tokens of the root alone: the image's, such as `nvx_layer=distro,0xd0003000,erofs:UUID`, and the upper's, such as `nvx_scratch=0xd0006000,ext4`. OpenVMM's arguments are the plan's, which attach the [checked files](#the-checked-handoff) of the image and the scratch -as block devices, then the VM in one process, NVX's default memory, the -kernel, the initramfs, that command line, no virtio console, the control +as block devices, then those of the export of the launch's +[shares](#shares), if any, then the VM in one process, NVX's default memory, +the kernel, the initramfs, that command line, no virtio console, the control console with the launch capability on standard input, and the outcome report. They name no workload identity, and no managed lifecycle: OpenVMM only checks the arguments of the managed lifecycle and labels its outcome report with it, @@ -706,8 +739,9 @@ The boot tokens carry only what NVX's boot contract defines. The launch sends the rest in the [root configuration](#root-configurations), once it attaches: the defaults, with the runtime directory that the caller names, as it is, so that the guest checks it, the size of each image in sectors, if it is a whole -number of them, which the guest checks against its block device, and the -overlay mode and the scratch's journal policy that the caller names, if any. +number of them, which the guest checks against its block device, the overlay +mode and the scratch's journal policy that the caller names, if any, and the +number of the mappings of the export's table, if the launch shares anything. The launch records what it launched in the sandbox's store, beside NVX's runtime records, in `runtime-adapter.json`, before it spawns the VMM: @@ -749,6 +783,41 @@ files keep anything else from taking while they are open, and OpenVMM inherits the scratch image's claim. The launch holds the checked files until the guest has assembled the root, or until it has torn down a launch that failed. +### Shares + +A launch may share host directories and regular files with the root, as +`sharing::HostFsSharing`: each `sharing::Share` at an absolute guest path in +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. + +`LayeredLaunch::share` attaches the export to the launch, which +`LayeredLaunch::export` returns, and announces its table, of at most 4,096 +mappings, in the root configuration. NVX's adapter plan +holds no export, so the launch attaches it itself: OpenVMM's arguments export +the shares through OpenVMM's aggregate virtio-fs device, as +`ExportPlan::openvmm_arguments` renders them, and OpenVMM appends the boot +tokens with which the guest mounts the export, out of the root. Before it +spawns the VM, `LayeredLaunch::prepare` checks the export with +`ExportPlan::verify`: the host still keeps its read-only mappings read-only, +and its directories and files, and the paths that the guest could move inside +the parts that it may write, still name what they named when the export was +planned. Once the guest is ready, the launch sends the table, +`LayeredLaunch::table`, in the [host's order](#the-hosts-order): each mapping +before those inside its target. + ### The step hook For tests, `launch` takes a step hook, `hook::StepHook`, from the variable @@ -768,10 +837,10 @@ The steps of a launch, in order, are: | Step | Where the launch is | | --- | --- | -| `checked` | It has checked the bindings of its images, and holds the checked files, which claim the scratch image, but has begun nothing in the store, and spawned nothing. A launch without a layered root reaches it too. | +| `checked` | It 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 store, and spawned nothing. A launch without a layered root reaches it too. | | `spawned` | The VMM runs, and the launch's journal records it, but the launch has not connected to the VM. | -| `ready` | The guest has assembled the layered root, and the launch has released the checked files. | -| `commit` | The guest agent has answered, and the launch is about to commit: its journal still records the VMM, which no runtime record names yet. | +| `ready` | The guest agent, of the build that the host ships, has assembled the layered root, and the launch has released the checked files, but has sent neither the mapping table nor `Start`. | +| `commit` | The guest has started the sandbox, if the VM has a layered root, the guest agent has answered, and the launch is about to commit: its journal still records the VMM, which no runtime record names yet. | A value that names no step or action is a usage error. Without the variable, the hook does nothing. @@ -802,7 +871,7 @@ the directory that `NVX_ARTIFACTS_DIR` names, and the guest agent's build from ends its VM. LAYERED, - `--distro FORMAT:PATH --distro-identity UUID --upper (scratch:PATH | ram) [--runtime-dir DIR] [--overlay-mode MODE] [--scratch-journal POLICY] [--first-partition-bound BYTES]`, + `--distro FORMAT:PATH --distro-identity UUID --upper (scratch:PATH | ram) [--runtime-dir DIR] [--overlay-mode MODE] [--scratch-journal POLICY] [--first-partition-bound BYTES] [SHARES]`, gives the VM a [layered root](#layered-roots). Its image is the file PATH, an EROFS file system, `erofs`, or a GPT disk, `gpt`, of which the guest expects the identity UUID: the file system's UUID, or the disk's GUID. Its upper is @@ -815,11 +884,30 @@ the directory that `NVX_ARTIFACTS_DIR` names, and the guest agent's build from launch checks the bindings that STORE stages for the image and the scratch image, and fails if STORE stages none for either, or one no longer holds, as [the checked handoff](#the-checked-handoff) describes. It records the - bindings that it checked. Once attached, the launch sends the root - configuration, and exchanges the `Ping` and the `Echo` once the guest - assembled the root. If the guest reports that a stage of the root failed - instead, the launch waits for the VM to end, abandons the launch, and checks - that the VM ended with the stage's status. + bindings that it checked. + + 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, 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. + + 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 + root is of the build that the host program ships, sends the mapping table, + then `Start`, and exchanges the `Ping` and the `Echo` once the guest started + the sandbox. A guest agent of another build gets nothing more, and the launch + fails, which ends the VM. If the guest reports that a stage of the root + failed instead, the launch waits for the VM to end, abandons the launch, and + checks that the VM ended with the stage's status. If the guest fails a + mapping, the launch sends no more of the table, nor `Start`, stops the guest, + which tears down the root, waits for the VM to end, abandons the launch, and + checks that the VM ended with a success and a complete teardown. - `reattach STORE` takes the sandbox's lock, and reattaches to its VM, which another process launched, and which it holds, although the VM is not its child, through its verified process identity. It reads the capability from @@ -846,8 +934,12 @@ runtime identity in which a mismatched VM differs), `pid` (the VMM's process ID), `instance` and `epoch` (the identity of the session that they attached to), `root` (`ready` or `failed`: how the guest answered the root configuration, or `failed` when a stop finds that the root's teardown failed), -`stage` and `problem` (the stage of the root that failed, such as `assembly`, -and the problem), `PROBE.KEY` (the fact KEY that the probe named PROBE found), +`build` (the build of the guest agent that assembled the root), `stage` and +`problem` (the stage of the root that failed, such as `assembly`, and the +problem, or the problem of a mapping that failed), `table` (`applied` or +`failed`: how the guest took the mapping table), `mapped` (how many of the +table's mappings the guest applied), `sandbox` (`started` once the guest +started the sandbox), `PROBE.KEY` (the fact KEY that the probe named PROBE found), `stopped` (`graceful`, `forced`, or `timed-out`), `outcome` (such as `success 0`, or `guest-exit 14` once the assembly failed), and `teardown` (`complete` or `incomplete`: whether the VMM's teardown of the VM completed, @@ -856,9 +948,11 @@ carriage returns as `\\`, `\n`, and `\r`. The commands exit with 0 once they did what they were asked, with 3 when they find the VM mismatched, with 4 when the guest reported that a stage of the root failed, and the VM ended with the stage's status and a complete teardown, with 5 when the -[step hook](#the-step-hook) ended a launch, and with 1, after a diagnostic on -standard error, when they fail. Without arguments, the program reports the -contract that it was built against. +[step hook](#the-step-hook) ended a launch, with 6 when the guest failed a +mapping of the table, and the VM stopped gracefully, with a success and a +complete teardown, and with 1, after a diagnostic on standard error, when they +fail. Without arguments, the program reports the contract that it was built +against. ## Tests @@ -942,7 +1036,18 @@ Windows. They cover: - a layered root: the configuration that the client sends, the guest's answers, ready with the guest agent's build or the stage that failed, probes and their facts, a stop that learns that the teardown failed, and answers - whose bodies do not decode, which end the client. + whose bodies do not decode, which end the client; +- the host's order: the root configuration that announces the table, the + guest's readiness, then each part of a table of no mapping, of one, and of + several parts, once the guest applied the one before, then `Start`, also in + records of 7 bytes; a guest agent of another build, and a root that failed, + after which the host sends nothing more; a table that does not encode, of + which the host sends nothing; a mapping that the guest fails at the first, a + middle, or the last part, after which the host sends no more of the table, + nor `Start`, but `Stop`, and learns how the stop ended, with or without a + teardown that failed, or as the session ends; and `Maps` or `Start` answered + with another kind, or failures whose bodies do not decode, which end the + client. Through a relay between its test broker and a pseudo-terminal, `tests/guest_session.rs` also checks that `guest::serve_terminal` and @@ -1075,6 +1180,22 @@ Windows: take, until the first one releases its checked files; - the record of a launch, with the bindings that it checked, over a scratch image and over RAM, and of a VM without a layered root; +- 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 + 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; +- the shares that a launch refuses: outside the authorized directories, also + through a link on Linux, without any, with an authorized directory that is + 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; +- `runtime-adapter-host launch` with shares that it cannot export, which it + refuses with status 1 before it creates the store, with artifacts that boot + nothing; - the command lines that the host program refuses, each with status 2. `tests/step_hook.rs` checks, in the host-only configuration, on Linux and @@ -1109,7 +1230,8 @@ hypervisor, in the host-only configuration: - `runtime-adapter-host stage` stages NVX's Ubuntu EROFS image over a copy of the scratch template, and `runtime-adapter-host launch` launches a VM with - them as its layered root, and exits; + them as its layered root, whose guest agent, of the build that the host + program ships, starts the sandbox, and exits; - another process's `reattach` holds the VM, which is not its child, and attaches a new session, in a later epoch of the same broker instance; - a third process's `stop` stops the VM: gracefully, with a successful outcome, @@ -1118,16 +1240,22 @@ hypervisor, in the host-only configuration: In another test, a host program that ships another build of the guest agent finds the VM mismatched in its initramfs, and signals nothing: the VM runs on, under the same runtime record, until the host program of the right runtime -stops it. +stops it. In a third, a launch of a host program that ships another build than +the one that it boots finds that build in the guest's readiness, sends it +neither the mapping table nor `Start`, and fails, which ends the VM, whose +scratch image the next launch claims. ### Layered scenarios `tests/vm_layered.rs` launches VMs with a layered root on a real hypervisor, in the host-only configuration, through the host program's commands, and checks that the guest's worker, outside PID 1, assembles the root, or reports the -stage that failed, and that the VM still ends cleanly. Each scenario stages -its images, then launches them, and is a row of the table that its -`scenarios!` macro declares, and an ignored test of its own: +stage that failed, and that the VM still ends cleanly. A launch whose guest +assembled the root also follows the host's order: the guest agent is of the +build that the host program ships, applies the empty mapping table, and starts +the sandbox. Each scenario stages its images, then launches them, and is a row +of the table that its `scenarios!` macro declares, and an ignored test of its +own: | Scenario | Root | Expects | | --- | --- | --- | diff --git a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs index 58ca387e9..29070500e 100644 --- a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs +++ b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs @@ -21,12 +21,12 @@ //! //! LAYERED, `--distro FORMAT:PATH --distro-identity UUID --upper (scratch:PATH | ram) //! [--runtime-dir DIR] [--overlay-mode MODE] [--scratch-journal POLICY] -//! [--first-partition-bound BYTES]`, gives the VM a layered root, which the guest agent's worker -//! assembles; see `runtime_adapter::layered` and `runtime_adapter::worker`. Its image is the raw -//! file PATH, an EROFS filesystem, `erofs`, or a GPT disk, `gpt`, of which the guest expects the -//! identity UUID: the file system's UUID, or the disk's GUID. Its upper is the ext4 scratch -//! image PATH, or RAM. The guest assembles the root below its runtime directory DIR, -//! `/run/runtime-adapter` by default, in the overlay mode MODE, `trusted-lowers` or +//! [--first-partition-bound BYTES] [SHARES]`, gives the VM a layered root, which the guest +//! agent's worker assembles; see `runtime_adapter::layered` and `runtime_adapter::worker`. Its +//! image is the raw file PATH, an EROFS filesystem, `erofs`, or a GPT disk, `gpt`, of which the +//! guest expects the identity UUID: the file system's UUID, or the disk's GUID. Its upper is the +//! ext4 scratch image PATH, or RAM. The guest assembles the root below its runtime directory +//! DIR, `/run/runtime-adapter` by default, in the overlay mode MODE, `trusted-lowers` or //! `untrusted-lowers`, checks a scratch's journal against the policy POLICY, `allowed` or //! `forbidden`, and refuses a first partition of a GPT disk larger than BYTES, with the guest's //! defaults, `trusted-lowers`, `allowed`, and 64 KiB, unless given. Before it spawns the VM, the @@ -34,10 +34,30 @@ //! metadata alone, and fails if STORE stages none for either, or one no longer holds, as for an //! image that changed after it was staged. It hands OpenVMM the files 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 bindings. Once attached, the launch sends the root -//! configuration, and exchanges the `Ping` and the `Echo` once the guest assembled the root. If -//! the guest reports that a stage of the root failed instead, the launch waits for the VM to -//! end, abandons it, and checks that the VM ended with the stage's status. +//! that failed, and records 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`. +//! +//! 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 +//! the guest agent's `Ready`, then checks that the guest agent is of the build that the host +//! ships, sends the mapping table, in order, then `Start`, and exchanges the `Ping` and the +//! `Echo` once the guest started the sandbox. A guest agent of another build gets nothing more, +//! and the launch fails, which terminates the VM. If the guest reports that a stage of the root +//! failed instead, the launch waits for the VM to end, abandons it, and checks that the VM ended +//! with the stage's status. If the guest fails a mapping, the launch sends no more of the table, +//! nor `Start`, asks the guest agent to stop, which tears down the root, waits for the VM to +//! end, abandons it, and checks that the VM ended with success and a complete teardown. //! - `runtime-adapter-host reattach STORE` reattaches to the VM of STORE, which an earlier process //! launched and which it holds although it is not its child, attaches a new session to it, in //! the session's next epoch, and exchanges a `Ping`, leaving the VM running. @@ -66,8 +86,12 @@ //! that the command attached to; //! - `root`: how the guest answered the root configuration: `ready` or `failed`, or `failed` //! once a stop finds that the root's teardown failed; +//! - `build`: the build of the guest agent that assembled the root; //! - `stage` and `problem`: the stage of the root that failed, such as `assembly`, and the -//! problem; +//! problem, or the problem of a mapping that failed; +//! - `table`: how the guest took the mapping table: `applied` or `failed`; +//! - `mapped`: how many of the table's mappings the guest applied; +//! - `sandbox`: `started` once the guest started the sandbox; //! - `PROBE.KEY`: the fact KEY that the probe named PROBE found; //! - `stopped`: how the stop ended: `graceful`, `forced`, or `timed-out`; //! - `outcome`: the category and status code of the VM's outcome report, such as `success 0`; @@ -81,9 +105,11 @@ //! //! They exit with 0 once they did what they were asked, with 3 when reattach finds the VM //! mismatched, with 4 when the guest reported that a stage of the root failed, and the VM ended -//! with the stage's status and a complete teardown, with 5 when the step hook ends a launch, and -//! with 1, after a diagnostic on standard error, when they fail. Anything else is a usage error, -//! with status 2, as is a value of `RUNTIME_ADAPTER_STEP_HOOK` that sets no step hook. +//! with the stage's status and a complete teardown, with 5 when the step hook ends a launch, with +//! 6 when the guest failed a mapping of the table, and the VM stopped gracefully, with success and +//! a complete teardown, and with 1, after a diagnostic on standard error, when they fail. Anything +//! else is a usage error, with status 2, as is a value of `RUNTIME_ADAPTER_STEP_HOOK` that sets no +//! step hook. use std::env; use std::error::Error; @@ -106,12 +132,13 @@ use nvx::{ErrorKind, Hypervisor}; use runtime_adapter::GUEST_BUILD; use runtime_adapter::frame::MAX_BODY; use runtime_adapter::hook::{Step, StepHook}; -use runtime_adapter::host::Client; +use runtime_adapter::host::{self, Client, ClientError, OrderError}; use runtime_adapter::initramfs; use runtime_adapter::layered::{LaunchRecord, LayeredLaunch}; use runtime_adapter::probe::{Probe, ProbeReply, escape}; -use runtime_adapter::root::{JournalPolicy, OverlayMode, RootFailure, RootReply}; +use runtime_adapter::root::{JournalPolicy, OverlayMode, RootFailure}; use runtime_adapter::runtime::Runtime; +use runtime_adapter::sharing::{HostFsSharing, Share}; use runtime_adapter::staged::Staged; /// How long a command waits for the sandbox's lock. @@ -134,6 +161,9 @@ const MISMATCHED: u8 = 3; /// The exit status of a command whose guest reported that a stage of the root failed, once the VM /// ended with the stage's status. const ROOT_FAILED: u8 = 4; +/// The exit status of a launch whose guest failed a mapping of the table, once the VM stopped +/// gracefully, with success and a complete teardown. +const TABLE_FAILED: u8 = 6; /// The command lines that the program accepts. const USAGE: &str = concat!( "usage: runtime-adapter-host\n", @@ -144,7 +174,9 @@ const USAGE: &str = concat!( " [--distro FORMAT:PATH --distro-identity UUID", " --upper (scratch:PATH | ram) [--runtime-dir DIR]\n", " [--overlay-mode MODE] [--scratch-journal POLICY]", - " [--first-partition-bound BYTES]]\n", + " [--first-partition-bound BYTES]\n", + " [--share HOST:TARGET[:ro]... [--deny PATH]... [--reserved TARGET]...", + " [--share-root DIR]...]]\n", " runtime-adapter-host (reattach | stop) STORE [--guest-build BUILD]\n", " runtime-adapter-host probe STORE PROBE... [--guest-build BUILD]", ); @@ -177,7 +209,7 @@ enum Command { /// The guest agent's build. guest_build: String, /// The VM's layered root, if it has one. - layered: Option, + layered: Option>, /// The step hook, for tests, if `RUNTIME_ADAPTER_STEP_HOOK` sets one. hook: Option, }, @@ -219,6 +251,8 @@ struct Layered { scratch_journal: Option, /// The most bytes that the first partition of a GPT disk may hold, unless the guest's default. first_partition_bound: Option, + /// The host paths that the launch shares with the root. + sharing: HostFsSharing, } /// The options of a command. @@ -244,6 +278,8 @@ struct Options { scratch_journal: Option, /// `--first-partition-bound`. first_partition_bound: Option, + /// `--share`, `--deny`, `--reserved`, and `--share-root`, in order. + sharing: HostFsSharing, } fn main() -> ExitCode { @@ -282,7 +318,7 @@ fn main() -> ExitCode { &guest_agent, hypervisor, &guest_build, - layered.as_ref(), + layered.as_deref(), hook.as_ref(), ), Command::Reattach { @@ -351,6 +387,17 @@ fn parse(arguments: Vec) -> Result { } } "launch" => { + let sharing = &options.sharing; + let share_options = [ + ("--deny", !sharing.denied().is_empty()), + ("--reserved", !sharing.reserved().is_empty()), + ("--share-root", !sharing.roots().is_empty()), + ]; + if sharing.shares().is_empty() + && let Some((option, _)) = share_options.iter().find(|(_, given)| *given) + { + return Err(format!("{option} needs --share")); + } let layered = match (options.distro, options.identity, options.upper) { (Some(_), None, _) => return Err("--distro needs --distro-identity".to_owned()), (Some(_), Some(_), None) => return Err("--distro needs --upper".to_owned()), @@ -362,16 +409,19 @@ fn parse(arguments: Vec) -> Result { { return Err("--first-partition-bound needs --distro gpt:PATH".to_owned()); } - (Some((format, distro)), Some(identity), Some(scratch)) => Some(Layered { - distro, - format, - identity, - scratch, - runtime: options.runtime, - overlay_mode: options.overlay_mode, - scratch_journal: options.scratch_journal, - first_partition_bound: options.first_partition_bound, - }), + (Some((format, distro)), Some(identity), Some(scratch)) => { + Some(Box::new(Layered { + distro, + format, + identity, + scratch, + runtime: options.runtime, + overlay_mode: options.overlay_mode, + scratch_journal: options.scratch_journal, + first_partition_bound: options.first_partition_bound, + sharing: options.sharing, + })) + } (None, Some(_), _) => { return Err("--distro-identity needs --distro".to_owned()); } @@ -385,6 +435,7 @@ fn parse(arguments: Vec) -> Result { "--first-partition-bound", options.first_partition_bound.is_some(), ), + ("--share", !options.sharing.shares().is_empty()), ]; if let Some((option, _)) = layered.iter().find(|(_, given)| *given) { return Err(format!("{option} needs --distro")); @@ -489,6 +540,19 @@ fn parse_option( format!("--first-partition-bound takes a positive number of bytes, not {value:?}") })?); } + ("launch", "--share") => { + let value = text(value)?; + let share = Share::parse(&value).ok_or_else(|| { + format!( + "--share takes HOST:TARGET or HOST:TARGET:ro, with an absolute guest path \ + TARGET, not {value:?}" + ) + })?; + options.sharing.add_share(share); + } + ("launch", "--deny") => options.sharing.add_denied(value), + ("launch", "--reserved") => options.sharing.add_reserved(text(value)?), + ("launch", "--share-root") => options.sharing.add_root(value), (_, "--guest-build") if name != "stage" && options.guest_build.is_none() => { options.guest_build = Some(text(value)?); } @@ -552,6 +616,10 @@ fn launch( launch.config.overlay_mode = layered.overlay_mode; launch.config.scratch_journal = layered.scratch_journal; launch.config.first_partition_bound = layered.first_partition_bound; + // The launch refuses shares that it cannot export before it touches the store. + if let Some(export) = layered.sharing.plan()? { + launch.share(export)?; + } Ok(launch) }) .transpose() @@ -640,28 +708,57 @@ fn launch( .map_err(|error| failed(format!("cannot connect to the VM: {}", describe(&error))))?; let mut client = attach(transport, &capability, deadline).map_err(failed)?; if let Some(layered) = &layered { - let reply = client - .configure_root(&layered.config, deadline) - .map_err(|error| { - failed(format!( - "the guest agent did not answer the root configuration: {}", - describe(&error) - )) - })?; - match reply { - RootReply::Ready(_) => { + match host::ready(&mut client, &layered.config, guest_build, deadline) { + Ok(ready) => { println!("root=ready"); + println!("build={}", escape(ready.build())); // The guest has assembled the root, so OpenVMM holds the images: the checked files // can go, which releases their guards, while OpenVMM keeps the scratch image's // claim. drop(prepared); deadline += step(hook, Step::Ready)?; } - RootReply::Failed(failure) => { + Err(OrderError::Root(failure)) => { print_failure(&failure); drop(client); return launch_failed(starting, &store, &failure).map_err(failed); } + // The host sends a guest agent of another build nothing more, and the VM ends once + // `starting` drops. + Err(OrderError::Build { reported, expected }) => { + println!("root=ready"); + println!("build={}", escape(&reported)); + return Err(failed(format!( + "the guest agent is of build {reported}, not of the host's build {expected}" + ))); + } + Err(error) => { + return Err(failed(format!( + "the guest agent did not answer the root configuration: {}", + describe(&error) + ))); + } + } + let table = layered.table(); + match host::start_sandbox(&mut client, &table, deadline) { + Ok(()) => { + println!("table=applied"); + println!("mapped={}", table.len()); + println!("sandbox=started"); + } + Err(OrderError::Mapping { failure, stop }) => { + println!("table=failed"); + println!("mapped={}", failure.applied()); + println!("problem={}", escape(failure.problem())); + drop(client); + return table_failed(starting, &store, stop).map_err(failed); + } + Err(error) => { + return Err(failed(format!( + "the guest agent did not start the sandbox: {}", + describe(&error) + ))); + } } } client @@ -710,6 +807,42 @@ fn launch_failed( Ok(ExitCode::from(ROOT_FAILED)) } +/// Ends the launch `starting` of the sandbox `store`, whose guest failed a mapping of the table, +/// and which the host asked to stop, as `stop` tells how the stop ended: once the guest agent +/// confirmed the stop, it waits for the VM to exit, then abandons the launch, which terminates a +/// VM that still runs, and checks that the VM stopped gracefully, with success and a complete +/// teardown. A guest agent that did not confirm the stop, or reported that the root's teardown +/// failed, fails the launch. +fn table_failed( + starting: Starting<'_>, + store: &SandboxStore, + stop: Result, ClientError>, +) -> Result { + let exited = match &stop { + Ok(_) => starting + .process() + .wait(Instant::now() + STOP_TIMEOUT) + .map_err(|error| format!("cannot wait for the VM to exit: {error}"))?, + Err(_) => false, + }; + starting.abandon().map_err(|error| describe(&error))?; + println!("stopped={}", if exited { "graceful" } else { "forced" }); + let failure = stop.map_err(|error| { + format!( + "the guest agent did not confirm the stop: {}", + describe(&error) + ) + })?; + if !exited { + return Err("the VM did not exit once the guest agent confirmed the stop".to_owned()); + } + if let Some(failure) = failure { + return Err(format!("once the mapping table failed, {failure}")); + } + check_outcome(store, "success", 0)?; + Ok(ExitCode::from(TABLE_FAILED)) +} + /// Prints the root's `failure`. fn print_failure(failure: &RootFailure) { println!("root=failed"); diff --git a/tests/runtime-adapter/src/hook.rs b/tests/runtime-adapter/src/hook.rs index 9d0ffdcf3..3b0e8f231 100644 --- a/tests/runtime-adapter/src/hook.rs +++ b/tests/runtime-adapter/src/hook.rs @@ -36,18 +36,21 @@ const PAUSE_POLL: Duration = Duration::from_millis(10); /// then, and before what it does next. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Step { - /// `checked`: the launch has checked the bindings of its images, 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. + /// `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. Checked, /// `spawned`: the VMM runs, and the launch's journal records it, but the launch has not /// connected to the VM. Spawned, - /// `ready`: the guest has assembled the layered root, and the launch has released the checked - /// files. A launch without a layered root never reaches it. + /// `ready`: the guest agent, of the build that the host ships, has assembled the layered root, + /// and the launch has released the checked files, but has sent neither the mapping table nor + /// `Start`. A launch without a layered root never reaches it. Ready, - /// `commit`: the guest agent has answered, and the launch is about to commit, so its journal - /// still records the VMM, which no runtime record names yet. + /// `commit`: the guest has started the sandbox, if the VM has a layered root, the guest agent + /// has answered, and the launch is about to commit, so its journal still records the VMM, + /// which no runtime record names yet. Commit, } diff --git a/tests/runtime-adapter/src/host.rs b/tests/runtime-adapter/src/host.rs index 5360a50c2..84ba3103a 100644 --- a/tests/runtime-adapter/src/host.rs +++ b/tests/runtime-adapter/src/host.rs @@ -2,18 +2,43 @@ //! control session between the host and the guest, and exchanges the adapter's own //! [framing](crate::frame) with the guest agent, through `nvx::host::nvxs` alone, over any platform //! transport. +//! +//! # The host's order +//! +//! A VM with a layered root is ready for workloads once its guest assembled the root and applied +//! the host's mapping table. The host runs that order over a [`Client`] in two steps, between +//! which it may do what the root's readiness allows, such as releasing the files that it handed +//! the VMM: +//! +//! 1. [`ready`] sends the root configuration, and waits for the guest agent's `Ready`, whose +//! build must be the one that the host ships: the host sends a guest agent of another build +//! nothing more, since it cannot tell what that build would do with it. +//! 2. [`start_sandbox`] sends the mapping table, in order, in parts that the guest applies one +//! after the other, then `Start`, which the guest confirms with `Started`. +//! +//! A part that the guest fails ends the order: the host sends no more of the table, nor `Start`, +//! so the guest starts no workload on a partial table, and asks the guest to stop, which tears +//! its root down, as NVX's managed lifecycle stops a guest whose mappings failed. The caller then +//! waits for the VM to end, and ends a VM whose guest did not stop, as it ends one after any other +//! failure of the order, an [`OrderError`]. + +mod map_reply; +mod order_error; use std::fmt; use std::time::Instant; use nvx::host::nvxs::{HostConnection, HostConnectionError}; use nvx::host::platform::Transport; -use nvx::protocol::CAPABILITY_LEN; use nvx::protocol::nvxs::Identity; +use nvx::protocol::{CAPABILITY_LEN, HostMap}; use crate::frame::{BodyError, Decoder, FrameError, Message}; use crate::probe::{Facts, Probe, ProbeReply}; -use crate::root::{Ready, RootConfig, RootFailure, RootReply}; +use crate::root::{MapFailure, Ready, RootConfig, RootFailure, RootReply, encode_maps}; + +pub use map_reply::MapReply; +pub use order_error::OrderError; /// A failure of a [`Client`]. #[derive(Debug)] @@ -91,7 +116,8 @@ impl From for ClientError { /// message in as many of the session's records as it takes, and [`receive`](Self::receive) /// returns the guest agent's next message, whether it spans records or shares one with the next /// message. [`ping`](Self::ping), [`echo`](Self::echo), [`configure_root`](Self::configure_root), -/// [`probe`](Self::probe), and [`stop`](Self::stop) each send a request and wait for its answer. +/// [`map`](Self::map), [`start`](Self::start), [`probe`](Self::probe), and [`stop`](Self::stop) +/// each send a request and wait for its answer. /// /// Besides the session's own buffers, the client holds at most the rest of one of the guest's /// records and one partial message, whose header [`Decoder`] checks before it takes any byte of @@ -246,6 +272,35 @@ impl Client { } } + /// Sends `part`, a part of the host's mapping table as [`encode_maps`] encodes it, in a + /// `Maps`, and waits until `deadline` for the guest agent's answer: `Mapped` once it applied the + /// part's mappings, or `MapFailed` once it refused the part or failed to apply one of them, + /// after which it refuses the rest of the table and the start. + /// + /// A part of more than [`MAX_BODY`](crate::frame::MAX_BODY) bytes fails before anything is + /// sent, and the client stays usable. + pub fn map(&mut self, part: &[u8], deadline: Instant) -> Result { + let request = Message::Maps(part.to_vec()); + match self.request(&request, deadline)? { + Message::Mapped => Ok(MapReply::Mapped), + Message::MapFailed(body) => { + self.decoded(MapFailure::decode(&body).map(MapReply::Failed)) + } + reply => Err(self.mismatched(&request, &reply)), + } + } + + /// Sends `Start`, and waits until `deadline` for the guest agent's `Started`, with which it + /// confirms that it started the sandbox, once it applied every mapping that the root + /// configuration announced. + pub fn start(&mut self, deadline: Instant) -> Result<(), ClientError> { + let request = Message::Start; + match self.request(&request, deadline)? { + Message::Started => Ok(()), + reply => Err(self.mismatched(&request, &reply)), + } + } + /// Sends `probe` to the guest agent of a VM with a layered root, and waits until `deadline` /// for its answer: the facts that the probe found, or the problem. pub fn probe(&mut self, probe: &Probe, deadline: Instant) -> Result { @@ -330,3 +385,58 @@ impl fmt::Debug for Client { .finish_non_exhaustive() } } + +/// Runs the first step of the host's order of a VM with a layered root over `client`: sends the +/// root configuration `config`, and waits until `deadline` for the guest agent's `Ready`, which it +/// returns once it names `build`, the guest agent's build that the host ships. +/// +/// Fails with [`OrderError::Root`] once the guest reports that a stage of the root failed, with +/// [`OrderError::Build`] for a guest agent of another build, to which it sends nothing more, and +/// with [`OrderError::Client`] once the client fails. +pub fn ready( + client: &mut Client, + config: &RootConfig, + build: &str, + deadline: Instant, +) -> Result { + match client + .configure_root(config, deadline) + .map_err(OrderError::Client)? + { + RootReply::Ready(ready) if ready.build() == build => Ok(ready), + RootReply::Ready(ready) => Err(OrderError::Build { + reported: ready.build().to_owned(), + expected: build.to_owned(), + }), + RootReply::Failed(failure) => Err(OrderError::Root(failure)), + } +} + +/// Runs the rest of the host's order over `client`, once the guest agent is [`ready`]: sends the +/// mapping table `table`, which the root configuration announced, in the parts that +/// [`encode_maps`] splits it into, each once the guest applied the one before, then `Start`, and +/// waits until `deadline` for `Started`. A table of no mapping sends no part. +/// +/// Fails with [`OrderError::Table`], before it sends anything, for a table that does not encode; +/// with [`OrderError::Mapping`] once the guest fails a part, after which it sends no more of the +/// table, nor `Start`, but asks the guest to stop; and with [`OrderError::Client`] once the client +/// fails. +pub fn start_sandbox( + client: &mut Client, + table: &[HostMap], + deadline: Instant, +) -> Result<(), OrderError> { + let parts = encode_maps(table).map_err(OrderError::Table)?; + for part in &parts { + match client.map(part, deadline).map_err(OrderError::Client)? { + MapReply::Mapped => {} + MapReply::Failed(failure) => { + // The guest starts no workload on a partial table, and tears its root down as it + // stops, which unmounts the mappings that it applied. + let stop = client.stop(deadline); + return Err(OrderError::Mapping { failure, stop }); + } + } + } + client.start(deadline).map_err(OrderError::Client) +} diff --git a/tests/runtime-adapter/src/host/map_reply.rs b/tests/runtime-adapter/src/host/map_reply.rs new file mode 100644 index 000000000..8acddbabf --- /dev/null +++ b/tests/runtime-adapter/src/host/map_reply.rs @@ -0,0 +1,14 @@ +//! How the guest agent answers a part of the host's mapping table, which +//! [`Client::map`](super::Client::map) returns: a [`MapReply`]. + +use crate::root::MapFailure; + +/// How the guest agent answered a part of the host's mapping table. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MapReply { + /// `Mapped`: it applied the part's mappings. + Mapped, + /// `MapFailed`: it refused the part, or failed to apply one of its mappings, after which it + /// refuses the rest of the table and the start. + Failed(MapFailure), +} diff --git a/tests/runtime-adapter/src/host/order_error.rs b/tests/runtime-adapter/src/host/order_error.rs new file mode 100644 index 000000000..6f101e2d1 --- /dev/null +++ b/tests/runtime-adapter/src/host/order_error.rs @@ -0,0 +1,76 @@ +//! How the [host's order](super#the-hosts-order) of a VM with a layered root fails, which +//! [`ready`](super::ready) and [`start_sandbox`](super::start_sandbox) return: an +//! [`OrderError`]. + +use std::fmt; + +use super::ClientError; +use crate::frame::BodyError; +use crate::root::{MapFailure, RootFailure}; + +/// A failure of the [host's order](super#the-hosts-order) of a VM with a layered root: what the +/// guest agent reported, or how the client failed, and what the host did about it. +#[derive(Debug)] +#[non_exhaustive] +pub enum OrderError { + /// The guest agent reported that a stage of the root failed, after which it ends. + Root(RootFailure), + /// The guest agent assembled the root, but is of another build than the one that the host + /// ships, so the host sent it nothing more. + Build { + /// The build that the guest agent reported in its `Ready`. + reported: String, + /// The build that the host ships. + expected: String, + }, + /// The guest agent failed the mapping table, so the host sent it no more of the table, nor + /// `Start`, and asked it to stop, which tears its root down. + Mapping { + /// How the table failed. + failure: MapFailure, + /// How the stop ended, as [`Client::stop`](super::Client::stop) returns it: the failure + /// of the root's teardown, if the guest reported one before it confirmed the stop. + stop: Result, ClientError>, + }, + /// The mapping table does not encode, so the host sent nothing of it. + Table(BodyError), + /// The client failed, which ended it. + Client(ClientError), +} + +impl fmt::Display for OrderError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Root(failure) => failure.fmt(formatter), + Self::Build { reported, expected } => write!( + formatter, + "the guest agent is of build {reported}, not of the host's build {expected}" + ), + Self::Mapping { failure, stop } => { + failure.fmt(formatter)?; + match stop { + Ok(None) => Ok(()), + Ok(Some(teardown)) => write!(formatter, "; then {teardown}"), + Err(error) => { + write!( + formatter, + "; the guest agent did not confirm the stop: {error}" + ) + } + } + } + Self::Table(error) => error.fmt(formatter), + Self::Client(error) => error.fmt(formatter), + } + } +} + +impl std::error::Error for OrderError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Table(error) => Some(error), + Self::Client(error) => Some(error), + Self::Root(_) | Self::Build { .. } | Self::Mapping { .. } => None, + } + } +} diff --git a/tests/runtime-adapter/src/layered.rs b/tests/runtime-adapter/src/layered.rs index ebf0c0893..03a82bad7 100644 --- a/tests/runtime-adapter/src/layered.rs +++ b/tests/runtime-adapter/src/layered.rs @@ -18,6 +18,16 @@ //! Everything else that the root needs goes in one root configuration, a [`RootConfig`], which the //! host sends once it attaches. //! +//! 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`]. +//! //! The host records what it launched in the sandbox's store, beside NVX's runtime records, as a //! [`LaunchRecord`], with the bindings that the launch checked. @@ -27,18 +37,20 @@ use std::path::Path; use std::time::Instant; use nvx::host::artifacts::absolute; +use nvx::host::filesystem::ExportPlan; use nvx::host::launch::DEFAULT_MEMORY_MIB; use nvx::host::sandbox::distro::{DISTRO_POLICY_REVISION, DistroBinding}; use nvx::host::sandbox::scratch::{CLAIM_RELEASE_WAIT, ScratchBinding}; use nvx::host::sandbox::{ LayerRole, PreparedLaunch, SandboxDistro, SandboxLayer, SandboxPlan, SandboxUpper, }; +use nvx::protocol::HostMap; use nvx::protocol::root::{ImageFormat, RootImage}; use nvx::uuid::Uuid; use nvx::{Error, Hypervisor, Result}; use serde_json::{Value, json}; -use crate::root::RootConfig; +use crate::root::{RootConfig, encode_maps}; /// The name of the record of a launch in the sandbox's store. pub const RECORD_NAME: &str = "runtime-adapter.json"; @@ -52,8 +64,9 @@ pub const SANDBOX_TOKEN: &str = "nvx_sandbox=1"; const SECTOR_SIZE: u64 = 512; /// A VM with a layered root: the plan of its image, its upper, and their boot tokens, the root -/// configuration that the host sends its guest agent, and the bindings that the sandbox's store -/// stages for its images, which the launch checks. +/// configuration that the host sends its guest agent, the bindings that the sandbox's store +/// stages for its images, which the launch checks, and the export of the host paths that it +/// shares with the root, if any. #[derive(Clone, Debug, PartialEq, Eq)] pub struct LayeredLaunch { /// The adapter's plan of the VM's block devices and of the boot tokens of its root. @@ -64,12 +77,15 @@ pub struct LayeredLaunch { /// [`Staged::bindings`](crate::staged::Staged::bindings) finds them, which /// [`prepare`](Self::prepare) checks, and the launch records. pub bindings: Bindings, + /// The export of the host paths that the launch shares with the root, which + /// [`share`](Self::share) sets, or `None` if it shares none. + export: Option, } impl LayeredLaunch { /// Returns the launch of the image at `distro`, in `format`, of which the guest expects the /// identity `identity`, over the ext4 scratch image at `scratch`, or over RAM without one, - /// without bindings. + /// without bindings, and without shares. /// /// The root configuration takes its defaults, with the runtime directory `runtime` instead, if /// any, as it is, so that the guest checks it, and with the size of each image, if it is a @@ -103,9 +119,42 @@ impl LayeredLaunch { plan, config, bindings: Bindings::default(), + export: None, }) } + /// Shares the host paths of `export`, which + /// [`HostFsSharing::plan`](crate::sharing::HostFsSharing::plan) plans, with the root: the + /// launch attaches the export, and the root configuration announces how many mappings its + /// table holds, which the host sends once the guest is ready; see [`table`](Self::table). + /// + /// Fails with [`ErrorKind::Unsupported`](nvx::ErrorKind::Unsupported) for an export whose + /// table the host cannot send, as `encode_maps` refuses it: one of more mappings than the + /// guest takes, or with a source or a target that the guest refuses. + pub fn share(&mut self, export: ExportPlan) -> Result<()> { + let table = export.guest_table(); + encode_maps(&table).map_err(|error| Error::unsupported(error.to_string()))?; + self.config.mapping_count = u32::try_from(table.len()) + .map_err(|_| Error::unsupported(format!("the export maps {} paths", table.len())))?; + self.export = Some(export); + Ok(()) + } + + /// Returns the export of the host paths that the launch shares with the root, which + /// [`share`](Self::share) attached, or `None` if it shares none. + pub fn export(&self) -> Option<&ExportPlan> { + self.export.as_ref() + } + + /// Returns the mapping table of the launch's export, in the order in which the guest applies + /// it, which `ExportPlan::guest_table` gives: each mapping before those inside its target. A + /// launch that shares nothing has an empty table. + pub fn table(&self) -> Vec { + self.export + .as_ref() + .map_or_else(Vec::new, ExportPlan::guest_table) + } + /// Returns the VM's kernel command line: [`SANDBOX_TOKEN`], then the boot tokens of the /// plan's root, without parameters of the caller. /// @@ -115,9 +164,13 @@ impl LayeredLaunch { } /// Checks the [bindings](Self::bindings) of the plan's images from their metadata alone, and - /// prepares the launch of the files that the checks opened, as NVX's `PreparedLaunch`. + /// the [export](Self::export) of the launch's shares, if any, and prepares the launch of the + /// files that the checks opened, as NVX's `PreparedLaunch`. /// - /// NVX's `DistroBinding::check` checks the image against its binding, under the distro policy + /// NVX's `ExportPlan::verify` checks that the host still keeps the export's read-only mappings + /// read-only, and that its directories and files, and the mapped and hidden paths that the + /// guest could move, still name what they named when the export was planned. NVX's + /// `DistroBinding::check` checks the image against its binding, under the distro policy /// that this build accepts, and `ScratchBinding::check` checks the scratch image of an upper /// on scratch against its own, and claims it for this sandbox, waiting `CLAIM_RELEASE_WAIT` /// for a claim that should be free. Neither reads the images. The prepared launch renders the @@ -137,8 +190,12 @@ impl LayeredLaunch { /// changed or was replaced after it was staged, and a missing image; with /// [`ErrorKind::TimedOut`](nvx::ErrorKind::TimedOut), and the code /// [`ErrorCode::ScratchClaimed`](nvx::ErrorCode::ScratchClaimed), for a scratch image that - /// another launch still claims; and otherwise as the checks and `PreparedLaunch::new` fail. + /// another launch still claims; and otherwise as `ExportPlan::verify`, the checks, and + /// `PreparedLaunch::new` fail. pub fn prepare(&self) -> Result { + if let Some(export) = &self.export { + export.verify()?; + } let distro = self.plan.distro(); let binding = self .bindings @@ -168,11 +225,13 @@ impl LayeredLaunch { /// Returns OpenVMM's arguments for the VM of `prepared`, the launch that /// [`prepare`](Self::prepare) prepared: its arguments, which attach the checked files as the - /// plan's block devices, then the VM in one process on `hypervisor`, NVX's default memory, the - /// kernel at `kernel`, the initramfs at `initramfs`, its kernel command line, which is the - /// [launch's](Self::kernel_command_line), no virtio console, the control console listening on - /// `endpoint` with the launch capability on standard input, and the outcome report at - /// `report`. + /// plan's block devices, those of the [export](Self::export) of the launch's shares, if any, + /// which `ExportPlan::openvmm_arguments` renders, then the VM in one process on `hypervisor`, + /// NVX's default memory, the kernel at `kernel`, the initramfs at `initramfs`, its kernel + /// command line, which is the [launch's](Self::kernel_command_line), no virtio console, the + /// control console listening on `endpoint` with the launch capability on standard input, and + /// the outcome report at `report`. OpenVMM appends the boot tokens with which the guest mounts + /// the export to the kernel command line. /// /// Unlike those of NVX's sandbox start, they name no workload identity, which the adapter's /// plan does not hold, and no managed lifecycle: OpenVMM only checks the arguments of the @@ -190,9 +249,15 @@ impl LayeredLaunch { report: &Path, ) -> Vec { debug_assert_eq!(prepared.plan(), &self.plan, "the launch prepared its plan"); + let export = self + .export + .as_ref() + .map(ExportPlan::openvmm_arguments) + .unwrap_or_default(); let mut arguments = prepared .openvmm_arguments() .iter() + .chain(&export) .map(OsString::from) .collect::>(); let memory = format!("{DEFAULT_MEMORY_MIB}M"); diff --git a/tests/runtime-adapter/src/lib.rs b/tests/runtime-adapter/src/lib.rs index 4d667ea9f..224429e87 100644 --- a/tests/runtime-adapter/src/lib.rs +++ b/tests/runtime-adapter/src/lib.rs @@ -8,7 +8,7 @@ //! items that `nvx` has without features, with the adapter's own [`frame`]s, and the bodies of //! its messages about a layered [`root`], its [`probe`]s, and [`exec`]utions; //! - host-only, with the `host` feature: the host API too, and the host program, in the `host`, -//! `runtime`, `initramfs`, `staged`, `layered`, and `hook` modules and the +//! `runtime`, `initramfs`, `staged`, `sharing`, `layered`, and `hook` modules and the //! `runtime-adapter-host` binary; //! - guest-only, with the `guest` feature: the guest API too, and the guest agent, in the `guest` //! and `worker` modules and the `runtime-adapter-guest` binary, a static @@ -30,14 +30,17 @@ //! The host program also launches VMs with a layered root, of an image over an upper, which it //! stages in the `staged` module, plans with NVX's typed plan for an adapter's own guest, and //! hands OpenVMM as the files that its checks of the staged bindings opened, in the `layered` -//! module. Its step hook, in the `hook` module, lets tests pause a launch, or end it as a crash -//! would, at a named step. In those VMs, the guest agent's PID 1 starts a worker, in the `worker` -//! module, which assembles the -//! root through `nvx::guest::root` outside PID 1, as the host's root configuration of the [`root`] -//! module asks, applies the host's mapping table from the host export, and runs the [`probe`]s -//! with which the host checks the root. Once the host started the sandbox, the worker also runs -//! several [`exec`]utions at once through NVX's execution engine and its guest binding, from one -//! loop that never blocks. +//! module, with the host directories and files that it shares with the root, whose export the +//! `sharing` module plans with NVX's `filesystem::plan`. Its step hook, in the `hook` module, lets +//! tests pause a launch, or end it as a crash would, at a named step. In those VMs, the guest +//! agent's PID 1 starts a worker, in the `worker` module, which assembles the root through +//! `nvx::guest::root` outside PID 1, as the host's root configuration of the [`root`] module asks, +//! applies the host's mapping table from the host export, and runs the [`probe`]s with which the +//! host checks the root. The host runs that order, in the `host` module: it checks that the guest +//! agent that assembled the root is of the build that it ships, sends the table, then starts the +//! sandbox, and tears down a VM whose order failed. Once the host started the sandbox, the worker +//! also runs several [`exec`]utions at once through NVX's execution engine and its guest binding, +//! from one loop that never blocks. pub mod exec; pub mod frame; @@ -56,6 +59,8 @@ pub mod root; #[cfg(feature = "host")] pub mod runtime; #[cfg(feature = "host")] +pub mod sharing; +#[cfg(feature = "host")] pub mod staged; #[cfg(all(feature = "guest", target_os = "linux"))] pub mod worker; diff --git a/tests/runtime-adapter/src/root.rs b/tests/runtime-adapter/src/root.rs index 5818131c9..41dcb762d 100644 --- a/tests/runtime-adapter/src/root.rs +++ b/tests/runtime-adapter/src/root.rs @@ -433,7 +433,7 @@ impl RootConfig { /// Describes why `path` is not an absolute and canonical guest path, if it is not: the rest of a /// sentence whose subject names the path. -fn path_problem(path: &str) -> Option<&'static str> { +pub(crate) fn path_problem(path: &str) -> Option<&'static str> { if path.len() > MAX_PATH_BYTES { return Some("is longer than 4095 bytes"); } diff --git a/tests/runtime-adapter/src/sharing.rs b/tests/runtime-adapter/src/sharing.rs new file mode 100644 index 000000000..51d2f8577 --- /dev/null +++ b/tests/runtime-adapter/src/sharing.rs @@ -0,0 +1,279 @@ +//! 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. +//! +//! [`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 +//! [`LayeredLaunch::share`](crate::layered::LayeredLaunch::share). + +use std::fs; +use std::path::{Path, PathBuf}; + +use nvx::host::filesystem::{ + self, ExportPlan, ExportRequest, Mapping, PathList, ReservedTargets, Target, +}; +use nvx::{Error, Result}; + +use crate::root::path_problem; + +/// A host directory or regular file that a launch shares with the root, at a guest path. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Share { + /// The host path. + host: PathBuf, + /// The absolute guest path in the root at which the guest maps it. + target: String, + /// Whether the guest may only read it. + read_only: bool, +} + +impl Share { + /// Returns the share of the host path `host` at the guest path `target`, read-only if + /// `read_only`, and read-write otherwise. [`HostFsSharing::plan`] checks both paths. + pub fn new(host: impl Into, target: impl Into, read_only: bool) -> Self { + Self { + host: host.into(), + target: target.into(), + read_only, + } + } + + /// Reads a share from the text of the host program's `--share`: `HOST:TARGET`, or + /// `HOST:TARGET:ro` for a read-only share. The guest path TARGET starts at the last `:/`, so + /// that the host path HOST may hold colons, as a Windows path does after its drive. + /// + /// Returns `None` for text without a host path, or without a guest path that starts with `/`. + pub fn parse(text: &str) -> Option { + let (rest, read_only) = match text.strip_suffix(":ro") { + Some(rest) => (rest, true), + None => (text, false), + }; + let (host, target) = rest.rsplit_once(":/")?; + (!host.is_empty()).then(|| Self::new(host, format!("/{target}"), read_only)) + } + + /// Returns the host path. + pub fn host(&self) -> &Path { + &self.host + } + + /// Sets the host path to `host`. + pub fn set_host(&mut self, host: impl Into) { + self.host = host.into(); + } + + /// Returns the absolute guest path in the root at which the guest maps the share. + pub fn target(&self) -> &str { + &self.target + } + + /// Sets the guest path at which the guest maps the share to `target`. + pub fn set_target(&mut self, target: impl Into) { + self.target = target.into(); + } + + /// Returns whether the guest may only read the share. + pub fn is_read_only(&self) -> bool { + self.read_only + } + + /// Sets whether the guest may only read the share. + pub fn set_read_only(&mut self, read_only: bool) { + self.read_only = read_only; + } +} + +/// 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. +/// +/// 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 +/// shares. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct HostFsSharing { + /// The shares, in order: `--share`. + shares: Vec, + /// Host paths that the export hides inside the shares: `--deny`. + denied: Vec, + /// Absolute guest paths of the trees that the adapter keeps for itself, beside those that the + /// container root keeps: `--reserved`. + reserved: Vec, + /// The host directories that the adapter authorizes, inside which each share must lie: + /// `--share-root`. + roots: Vec, +} + +impl HostFsSharing { + /// Returns the shares, in order: `--share`. + pub fn shares(&self) -> &[Share] { + &self.shares + } + + /// Sets the shares to `shares`, in order, in place of the earlier ones. + pub fn set_shares(&mut self, shares: Vec) { + self.shares = shares; + } + + /// Adds `share` after the other shares, as `--share` does. + pub fn add_share(&mut self, share: Share) { + self.shares.push(share); + } + + /// Returns the host paths that the export hides inside the shares: `--deny`. + pub fn denied(&self) -> &[PathBuf] { + &self.denied + } + + /// Sets the host paths that the export hides inside the shares to `denied`, in place of the + /// earlier ones. + pub fn set_denied(&mut self, denied: Vec) { + self.denied = denied; + } + + /// Adds `path` after the other host paths that the export hides, as `--deny` does. + pub fn add_denied(&mut self, path: impl Into) { + self.denied.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] { + &self.reserved + } + + /// Sets the guest trees that the adapter keeps for itself to `reserved`, in place of the + /// earlier ones. + pub fn set_reserved(&mut self, reserved: Vec) { + self.reserved = reserved; + } + + /// Adds `target` after the other guest trees that the adapter keeps, as `--reserved` does. + pub fn add_reserved(&mut self, target: impl Into) { + self.reserved.push(target.into()); + } + + /// Returns the host directories that the adapter authorizes, inside which each share must + /// lie: `--share-root`. + pub fn roots(&self) -> &[PathBuf] { + &self.roots + } + + /// Sets the host directories that the adapter authorizes to `roots`, in place of the earlier + /// ones. + pub fn set_roots(&mut self, roots: Vec) { + self.roots = roots; + } + + /// Adds `root` after the other host directories that the adapter authorizes, as + /// `--share-root` does. + pub fn add_root(&mut self, root: impl Into) { + self.roots.push(root.into()); + } + + /// Plans the export of the shares with NVX's `filesystem::plan`, or returns `None` without + /// shares. + /// + /// 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. + /// + /// 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 + /// share that is not accessible or lies outside every root, and as `filesystem::plan` refuses + /// the request, as for a target that the root keeps, or a read-only share inside a read-write + /// one. + pub fn plan(&self) -> Result> { + for target in &self.reserved { + if let Some(problem) = path_problem(target) { + return Err(Error::unsupported(format!( + "--reserved entry {target:?} {problem}" + ))); + } + } + if self.shares.is_empty() { + return Ok(None); + } + let roots = self + .roots + .iter() + .map(|root| { + let canonical = resolve("--share-root", root)?; + if !canonical.is_dir() { + return Err(Error::unsupported(format!( + "--share-root entry {} is not a directory", + root.display() + ))); + } + Ok(canonical) + }) + .collect::>>()?; + for share in &self.shares { + authorize(&roots, &share.host)?; + } + let mappings = self + .shares + .iter() + .map(|share| Mapping { + name: "--share", + path: &share.host, + target: Target::Guest(&share.target), + read_only: share.read_only, + }) + .collect::>(); + let container = ReservedTargets::CONTAINER_ROOT; + let trees = container + .trees + .iter() + .copied() + .chain(self.reserved.iter().map(String::as_str)) + .collect::>(); + let mut request = ExportRequest::new( + &mappings, + ReservedTargets { + trees: &trees, + exact: container.exact, + }, + ); + request.denied = PathList { + name: "--deny", + paths: &self.denied, + }; + let export = filesystem::plan(&request)?; + for child in export.iter().flat_map(|export| &export.children) { + authorize(&roots, &child.root)?; + } + Ok(export) + } +} + +/// Resolves the links of `path`, an entry of the option `name`, with `fs::canonicalize`. +/// +/// Fails with [`ErrorKind::Unsupported`](nvx::ErrorKind::Unsupported) for a path that does not +/// resolve, as `filesystem::plan` does. +fn resolve(name: &str, path: &Path) -> Result { + fs::canonicalize(path).map_err(|error| { + Error::unsupported(format!("{name} entry {} is not accessible", path.display())) + .with_source(error) + }) +} + +/// Checks that the share at `path` lies inside one of `roots`, the authorized host directories, +/// resolved as [`resolve`] resolves it. +/// +/// Fails with [`ErrorKind::Unsupported`](nvx::ErrorKind::Unsupported) for a share that does not +/// resolve, or lies outside every root. +fn authorize(roots: &[PathBuf], path: &Path) -> Result<()> { + let canonical = resolve("--share", path)?; + if roots.iter().any(|root| canonical.starts_with(root)) { + return Ok(()); + } + Err(Error::unsupported(format!( + "--share entry {} lies outside the host directories that --share-root authorizes", + path.display() + ))) +} diff --git a/tests/runtime-adapter/tests/host_session.rs b/tests/runtime-adapter/tests/host_session.rs index 5f7998f67..7ffc22fca 100644 --- a/tests/runtime-adapter/tests/host_session.rs +++ b/tests/runtime-adapter/tests/host_session.rs @@ -7,8 +7,10 @@ //! messages that span records and records that share messages, partial records, record lengths, //! ERROR payloads, sequence and identity errors, resets and disconnects in the middle of a message, //! timeouts, sends that fail, the answers that requests check, the bounded buffer of the adapter's -//! decoder, and the requests of a layered root: its configuration, its probes, and a stop whose -//! teardown failed. +//! decoder, the requests of a layered root: its configuration, its probes, and a stop whose +//! teardown failed, and the host's order of a layered root: the guest agent's readiness and build, +//! the mapping table in parts, and `Start`, and each failure of the order, after which the host +//! sends nothing more, or stops the guest. #![cfg(feature = "host")] use std::collections::VecDeque; @@ -23,13 +25,16 @@ use nvx::protocol::nvxs::{ ErrorCode, Identity, Record, RecordError, RecordParser, RecordType, SessionError, encode_record, }; use nvx::protocol::{ - CAPABILITY_LEN, HostMap, OUTER_HEADER_LEN, OUTER_MAX_PAYLOAD, OUTER_MIN_RECEIVE_WINDOW, + CAPABILITY_LEN, HostMap, MAX_HOST_MAPPINGS, MAX_MAP_PATH_BYTES, OUTER_HEADER_LEN, + OUTER_MAX_PAYLOAD, OUTER_MIN_RECEIVE_WINDOW, }; use runtime_adapter::GUEST_BUILD; use runtime_adapter::frame::{Decoder, FrameError, HEADER_LEN, MAX_BODY, MAX_TEXT_BODY, Message}; -use runtime_adapter::host::{Client, ClientError}; +use runtime_adapter::host::{self, Client, ClientError, MapReply, OrderError}; use runtime_adapter::probe::{Facts, Probe, ProbeReply}; -use runtime_adapter::root::{Ready, RootConfig, RootFailure, RootReply, RootStage, encode_maps}; +use runtime_adapter::root::{ + MapFailure, Ready, RootConfig, RootFailure, RootReply, RootStage, decode_maps, encode_maps, +}; /// Launch capability that the test broker accepts. const CAPABILITY: [u8; CAPABILITY_LEN] = [0xa5; CAPABILITY_LEN]; @@ -304,6 +309,20 @@ impl Broker { message } + /// Takes the host's next message, which must be `expected`, and answers it with `reply`. + fn expect(&mut self, expected: Message, reply: &Message) { + assert_eq!(self.messages(1), [expected]); + self.send_data(&reply.encode().unwrap(), OUTER_MAX_PAYLOAD); + } + + /// Checks that the host sends nothing for a while, as when it waits for an answer. + fn assert_quiet(&self) { + let mut byte = [0]; + let read = self.from_host.read(&mut byte, Some(SHORT)); + let quiet = matches!(&read, Err(error) if error.kind() == io::ErrorKind::TimedOut); + assert!(quiet, "the host sent more: {read:?}"); + } + /// Serves the host as the guest agent does until it answered `Stop`: it answers the messages /// that each of the host's records completes, together, in DATA records of at most `size` /// bytes. Returns the host's messages. @@ -447,12 +466,15 @@ fn with_length(record_type: RecordType, identity: Identity, sequence: u64, lengt bytes } -/// Returns the guest agent's answer to `message`. +/// Returns the guest agent's answer to `message`, in a guest whose root and mappings succeed. fn answer(message: &Message) -> Message { match message { Message::Ping => Message::Pong, Message::Echo(body) => Message::Echo(body.clone()), Message::Stop => Message::Stopped, + Message::RootConfig(_) => ready_message(), + Message::Maps(_) => Message::Mapped, + Message::Start => Message::Started, message => panic!("the host sent {message:?}, which only the guest sends"), } } @@ -493,12 +515,60 @@ fn ready_message() -> Message { /// Returns the body of a part of a mapping table, of one mapping. fn maps_part() -> Vec { - let table = [HostMap { - source: "dir".to_owned(), - target: "/srv/dir".to_owned(), - read_only: true, - }]; - encode_maps(&table).unwrap().remove(0) + encode_maps(&[mapping("dir", "/srv/dir", true)]) + .unwrap() + .remove(0) +} + +/// Returns a mapping of the host's mapping table. +fn mapping(source: &str, target: &str, read_only: bool) -> HostMap { + HostMap { + source: source.to_owned(), + target: target.to_owned(), + read_only, + } +} + +/// Returns a mapping table of long paths, which travels in several parts. +fn long_table() -> Vec { + let long = "p".repeat(MAX_MAP_PATH_BYTES - 6); + (0..100) + .map(|index| { + let source = format!("{index:03}/{long}"); + mapping(&source, &format!("/{source}"), index % 3 == 0) + }) + .collect() +} + +/// Returns the root configuration that announces the mappings of `table`. +fn announcing(table: &[HostMap]) -> RootConfig { + RootConfig { + mapping_count: u32::try_from(table.len()).unwrap(), + ..RootConfig::default() + } +} + +/// Runs the host's order with `client` in a thread, for the mapping table `table`: `ready`, with +/// the guest agent's build that the host program ships, then `start_sandbox`. The thread returns +/// the client with the guest's readiness, or how the order failed. +fn order(client: Client, table: Vec) -> JoinHandle<(Client, Result)> { + spawn(client, move |client| { + let ready = host::ready(client, &announcing(&table), GUEST_BUILD, deadline())?; + host::start_sandbox(client, &table, deadline())?; + Ok(ready) + }) +} + +/// Checks that the host sent nothing more once its order ended: the host's stream ends at a record +/// boundary once its client, which `ordering` returns, drops. Returns how the order ended. +fn ended( + mut broker: Broker, + ordering: JoinHandle<(Client, Result)>, +) -> OrderError { + let (client, ordered) = joined(ordering); + drop(client); + assert!(broker.read().is_none(), "the host sent more"); + ordered.unwrap_err() } /// Checks that a failure ended `client`: every later call fails. @@ -1182,9 +1252,11 @@ fn requests_check_their_answers_and_end_the_client_when_they_fail() { client.probe(&probe, deadline()).map(drop) }; let stop: Request = |client| client.stop(deadline()).map(drop); + let map: Request = |client| client.map(&maps_part(), deadline()).map(drop); + let start: Request = |client| client.start(deadline()); let teardown = RootFailure::new(RootStage::Teardown, "busy").encode(); // Each request, and answers that don't answer it. - let cases: [(&str, Request, Vec, Kinds); 11] = [ + let cases: [(&str, Request, Vec, Kinds); 16] = [ ( "Ping answered with Stopped", |client| client.ping(deadline()), @@ -1254,6 +1326,36 @@ fn requests_check_their_answers_and_end_the_client_when_they_fail() { vec![ready_message()], Some((9, 7)), ), + ( + "a part of the mapping table answered with Pong", + map, + vec![Message::Pong], + Some((22, 2)), + ), + ( + "a part of the mapping table answered with Started", + map, + vec![Message::Started], + Some((22, 26)), + ), + ( + "a part of the mapping table answered with Ready", + map, + vec![ready_message()], + Some((22, 7)), + ), + ( + "Start answered with Mapped", + start, + vec![Message::Mapped], + Some((25, 23)), + ), + ( + "Start answered with a mapping's failure", + start, + vec![Message::MapFailed(MapFailure::new(0, "late").encode())], + Some((25, 24)), + ), ]; for (name, request, replies, kinds) in cases { let (mut broker, client) = attached(FIRST, usize::MAX); @@ -1378,7 +1480,8 @@ fn answers_whose_bodies_do_not_decode_end_the_client() { client.probe(&probe, deadline()).map(drop) }; let stop: Request = |client| client.stop(deadline()).map(drop); - let cases: [(&str, Request, Message, &str); 9] = [ + let map: Request = |client| client.map(&maps_part(), deadline()).map(drop); + let cases: [(&str, Request, Message, &str); 11] = [ ( "a readiness without a build", configure, @@ -1433,6 +1536,18 @@ fn answers_whose_bodies_do_not_decode_end_the_client() { Message::RootFailed(vec![5, 0xff]), "is not UTF-8 text", ), + ( + "a mapping's failure that ends before its count does", + map, + Message::MapFailed(vec![1, 0, 0]), + "a failure of the mapping table ends early", + ), + ( + "a mapping's failure that is not UTF-8 text", + map, + Message::MapFailed(vec![0, 0, 0, 0, 0xff]), + "a failure of the mapping table is not UTF-8 text", + ), ]; for (name, request, reply, problem) in cases { let (mut broker, client) = attached(FIRST, usize::MAX); @@ -1448,3 +1563,244 @@ fn answers_whose_bodies_do_not_decode_end_the_client() { assert_ended(&mut client); } } + +#[test] +fn the_host_orders_a_layered_root_ready_then_its_table_in_parts_then_start() { + let long = long_table(); + assert!(encode_maps(&long).unwrap().len() >= 3); + // Tables of no mapping, which sends no part, of one, of several in one part, and of several + // parts. + let tables = [ + Vec::new(), + vec![mapping("0", "/srv/dir", true)], + vec![ + mapping("0", "/srv/dir", true), + mapping("1", "/srv/file.txt", true), + mapping("0/inner", "/srv/dir/inner", false), + ], + long, + ]; + for table in tables { + let count = table.len(); + let parts = encode_maps(&table).unwrap(); + let (mut broker, client) = attached(FIRST, usize::MAX); + let ordering = order(client, table.clone()); + // The root configuration announces the table, and the host waits for the guest's + // readiness. + let config = Message::RootConfig(announcing(&table).encode()); + assert_eq!(broker.messages(1), [config], "{count} mappings"); + broker.assert_quiet(); + broker.send_data(&ready_message().encode().unwrap(), OUTER_MAX_PAYLOAD); + // Then each part of the table, once the guest applied the one before, then `Start`. + for part in parts { + assert_eq!( + broker.messages(1), + [Message::Maps(part)], + "{count} mappings" + ); + broker.assert_quiet(); + broker.send_data(&Message::Mapped.encode().unwrap(), OUTER_MAX_PAYLOAD); + } + broker.expect(Message::Start, &Message::Started); + let (client, ordered) = joined(ordering); + assert_eq!(ordered.unwrap(), ready(), "{count} mappings"); + + // The client stays usable. + let pinging = spawn(client, |client| client.ping(deadline())); + assert_eq!(broker.answer(OUTER_MAX_PAYLOAD), Message::Ping); + joined(pinging).1.unwrap(); + } + + // The client returns how the guest took a part of the table. + let (mut broker, client) = attached(FIRST, usize::MAX); + let applying = spawn(client, |client| client.map(&maps_part(), deadline())); + broker.expect(Message::Maps(maps_part()), &Message::Mapped); + let (client, mapped) = joined(applying); + assert_eq!(mapped.unwrap(), MapReply::Mapped); + let applying = spawn(client, |client| client.map(&maps_part(), deadline())); + let failure = MapFailure::new(0, "the target /srv/dir is not a directory"); + broker.expect( + Message::Maps(maps_part()), + &Message::MapFailed(failure.encode()), + ); + assert_eq!(joined(applying).1.unwrap(), MapReply::Failed(failure)); + + // The whole order, a `Ping`, and a stop, in records of 7 bytes each way. + let table = vec![ + mapping("0", "/srv/dir", true), + mapping("0/inner", "/srv/dir/inner", false), + ]; + let (mut broker, transport) = connect(FIRST, 7); + let serving = thread::spawn(move || { + broker.attach(); + let served = broker.serve(7); + assert!(broker.read().is_none()); + served + }); + let mut client = Client::attach(transport, &CAPABILITY, deadline()).unwrap(); + let readiness = host::ready(&mut client, &announcing(&table), GUEST_BUILD, deadline()).unwrap(); + assert_eq!(readiness.build(), GUEST_BUILD); + host::start_sandbox(&mut client, &table, deadline()).unwrap(); + client.ping(deadline()).unwrap(); + assert_eq!(client.stop(deadline()).unwrap(), None); + drop(client); + let mut expected = vec![Message::RootConfig(announcing(&table).encode())]; + expected.extend(encode_maps(&table).unwrap().into_iter().map(Message::Maps)); + expected.extend([Message::Start, Message::Ping, Message::Stop]); + assert_eq!(joined(serving), expected); +} + +#[test] +fn the_host_sends_nothing_more_to_a_guest_agent_of_another_build_or_whose_root_failed() { + let table = vec![mapping("0", "/srv/dir", true)]; + let config = || Message::RootConfig(announcing(&table).encode()); + + // A guest agent of another build assembled the root: the host sends it neither the table nor + // `Start`. + let (mut broker, client) = attached(FIRST, usize::MAX); + let ordering = order(client, table.clone()); + let another = Ready::new("another-build").unwrap(); + broker.expect(config(), &Message::Ready(another.encode())); + let error = ended(broker, ordering); + let other = matches!( + &error, + OrderError::Build { reported, expected } + if reported == "another-build" && expected == GUEST_BUILD + ); + assert!(other, "{error:?}"); + assert_eq!( + error.to_string(), + format!("the guest agent is of build another-build, not of the host's build {GUEST_BUILD}") + ); + + // The guest reports that a stage of the root failed, after which it ends. + for stage in RootStage::ALL { + let failure = RootFailure::new(stage, format!("the {stage} failed")); + let (mut broker, client) = attached(FIRST, usize::MAX); + let ordering = order(client, table.clone()); + broker.expect(config(), &Message::RootFailed(failure.encode())); + let error = ended(broker, ordering); + let failed = matches!(&error, OrderError::Root(reported) if *reported == failure); + assert!(failed, "{stage}: {error:?}"); + assert_eq!(error.to_string(), failure.to_string()); + } + + // The client failed, as when the guest agent answers the root configuration with another + // kind. + let (mut broker, client) = attached(FIRST, usize::MAX); + let ordering = order(client, table.clone()); + broker.expect(config(), &Message::Pong); + let error = ended(broker, ordering); + let failed = matches!( + error, + OrderError::Client(ClientError::Reply { + request: 6, + reply: 2 + }) + ); + assert!(failed, "{error:?}"); + + // A table that does not encode fails before the host sends any of it, and the client stays + // usable. + let (mut broker, client) = attached(FIRST, usize::MAX); + let too_many = vec![mapping("a", "/a", false); MAX_HOST_MAPPINGS + 1]; + let starting = spawn(client, move |client| { + host::start_sandbox(client, &too_many, deadline()) + }); + let (client, started) = joined(starting); + let refused = matches!( + &started, + Err(OrderError::Table(error)) + if error.problem().starts_with("the mapping table does not encode") + ); + assert!(refused, "{started:?}"); + let pinging = spawn(client, |client| client.ping(deadline())); + assert_eq!(broker.answer(OUTER_MAX_PAYLOAD), Message::Ping); + joined(pinging).1.unwrap(); +} + +#[test] +fn a_mapping_that_the_guest_fails_ends_the_table_and_the_host_stops_the_guest() { + let table = long_table(); + let parts = encode_maps(&table).unwrap(); + let config = Message::RootConfig(announcing(&table).encode()); + let teardown = RootFailure::new(RootStage::Teardown, "failed to unmount /srv/000: busy"); + // The guest fails the first, a middle, or the last part, then confirms the stop, after a + // teardown that succeeded or failed. + let cases = [ + (0, None), + (parts.len() / 2, None), + (parts.len() - 1, None), + (parts.len() / 2, Some(teardown)), + ]; + for (failing, teardown) in cases { + let (mut broker, client) = attached(FIRST, usize::MAX); + let ordering = order(client, table.clone()); + broker.expect(config.clone(), &ready_message()); + for part in &parts[..failing] { + broker.expect(Message::Maps(part.clone()), &Message::Mapped); + } + // The guest applied the mappings before the failing part. + let applied = decode_maps(&parts[failing]).unwrap().first; + let failure = MapFailure::new(applied, "the target is not a directory"); + broker.expect( + Message::Maps(parts[failing].clone()), + &Message::MapFailed(failure.encode()), + ); + // The host sends no more of the table, nor `Start`, but asks the guest to stop. + assert_eq!(broker.messages(1), [Message::Stop], "part {failing}"); + let mut replies = Vec::new(); + replies.extend( + teardown + .iter() + .map(|teardown| Message::RootFailed(teardown.encode())), + ); + replies.push(Message::Stopped); + broker.send_data(&encode(&replies), OUTER_MAX_PAYLOAD); + let error = ended(broker, ordering); + let mut description = failure.to_string(); + if let Some(teardown) = &teardown { + description = format!("{description}; then {teardown}"); + } + assert_eq!(error.to_string(), description); + let OrderError::Mapping { + failure: found, + stop, + } = &error + else { + panic!("part {failing}: {error:?}"); + }; + assert_eq!(*found, failure, "part {failing}"); + assert!(matches!(stop, Ok(found) if *found == teardown), "{stop:?}"); + } + + // A guest agent that does not confirm the stop, as when its session ends, fails the stop, + // which the failure reports. + let (mut broker, client) = attached(FIRST, usize::MAX); + let ordering = order(client, table.clone()); + broker.expect(config, &ready_message()); + let failure = MapFailure::new(0, "the target is not a directory"); + broker.expect( + Message::Maps(parts[0].clone()), + &Message::MapFailed(failure.encode()), + ); + assert_eq!(broker.messages(1), [Message::Stop]); + broker.close(); + let error = ended(broker, ordering); + let OrderError::Mapping { + failure: found, + stop, + } = &error + else { + panic!("{error:?}"); + }; + assert_eq!(*found, failure); + assert!(matches!(stop, Err(ClientError::Session(_))), "{stop:?}"); + let description = error.to_string(); + assert!( + description.starts_with(&format!( + "{failure}; the guest agent did not confirm the stop: " + )), + "{description}" + ); +} diff --git a/tests/runtime-adapter/tests/layered_launch.rs b/tests/runtime-adapter/tests/layered_launch.rs index b74b48f8d..4569de3d9 100644 --- a/tests/runtime-adapter/tests/layered_launch.rs +++ b/tests/runtime-adapter/tests/layered_launch.rs @@ -1,8 +1,10 @@ //! The host program stages the images of a layered root, plans a VM with a layered root with NVX's //! typed plan for an adapter's own guest, checks the staged bindings of its images and hands -//! OpenVMM the checked files, composes OpenVMM's arguments without NVX's managed lifecycle, -//! records what it launches, with the bindings that it checked, and refuses options that do not -//! make a command. The tests need no hypervisor. +//! OpenVMM the checked files, plans the export of the host paths that it shares with the root +//! inside the directories that the adapter authorizes, attaches it, and verifies it before the +//! launch, composes OpenVMM's arguments without NVX's managed lifecycle, records what it launches, +//! with the bindings that it checked, and refuses options that do not make a command, and shares +//! that it cannot export before it touches the store. The tests need no hypervisor. #![cfg(feature = "host")] use std::ffi::OsString; @@ -14,13 +16,18 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, Instant}; +use nvx::host::artifacts::Artifacts; +use nvx::host::filesystem::GUEST_EXPORT; use nvx::host::sandbox::SandboxPlan; use nvx::host::sandbox::scratch::CLAIM_RELEASE_WAIT; use nvx::protocol::root::{ImageFormat, RootImage, RootLayout, RootUpper}; +use nvx::protocol::{HostMap, MAX_HOST_MAPPINGS}; use nvx::uuid::Uuid; use nvx::{ErrorCode, ErrorKind, Hypervisor}; +use runtime_adapter::hook::STEP_HOOK; use runtime_adapter::layered::{Bindings, LaunchRecord, LayeredLaunch, RECORD_NAME, SANDBOX_TOKEN}; use runtime_adapter::root::RootConfig; +use runtime_adapter::sharing::{HostFsSharing, Share}; use runtime_adapter::staged::Staged; use serde_json::{Value, json}; @@ -93,6 +100,65 @@ fn replace(path: &Path) { fs::rename(©, path).unwrap(); } +/// Returns the share of the host path `host` at the guest path `target`. +fn share(host: &Path, target: &str, read_only: bool) -> Share { + Share::new(host, target, read_only) +} + +/// Lays out the host sources of the tests' shares in `directory`, and returns the directory that +/// holds them, `sources`: the directory `dir`, with the file `marker`, the file `secret`, and the +/// directory `inner`, the file `file.txt`, and the directory `out`, with the directory `sub`. +fn sources(directory: &Path) -> PathBuf { + let sources = directory.join("sources"); + fs::create_dir_all(sources.join("dir").join("inner")).unwrap(); + fs::write(sources.join("dir").join("marker"), "marker").unwrap(); + fs::write(sources.join("dir").join("secret"), "secret").unwrap(); + fs::write(sources.join("file.txt"), "file").unwrap(); + fs::create_dir_all(sources.join("out").join("sub")).unwrap(); + sources +} + +/// Returns the tests' sharing of `sources`, which [`sources`] lays out, below `/srv/maps`: `dir` +/// read-only, `dir/inner` read-write inside it, `file.txt` read-only, and `out` read-write, with +/// `dir/secret` hidden, `/opt/adapter` reserved for the adapter, and `sources` authorized. +fn sharing(sources: &Path) -> HostFsSharing { + let dir = sources.join("dir"); + let mut sharing = HostFsSharing::default(); + sharing.set_shares(vec![ + share(&dir, "/srv/maps/dir", true), + share(&dir.join("inner"), "/srv/maps/dir/inner", false), + share(&sources.join("file.txt"), "/srv/maps/file.txt", true), + share(&sources.join("out"), "/srv/maps/out", false), + ]); + sharing.set_denied(vec![dir.join("secret")]); + sharing.set_reserved(vec!["/opt/adapter".to_owned()]); + sharing.set_roots(vec![sources.to_owned()]); + sharing +} + +/// Sets the guest path of the share at `index` of `sharing` to `target`. +fn retarget(sharing: &mut HostFsSharing, index: usize, target: &str) { + let mut shares = sharing.shares().to_vec(); + shares[index].set_target(target); + sharing.set_shares(shares); +} + +/// Returns the canonical path of `path`, as an export plan names it: without the verbatim prefix +/// that Windows gives it. +fn canonical(path: &Path) -> PathBuf { + let canonical = fs::canonicalize(path).unwrap(); + let text = canonical.to_str().unwrap(); + PathBuf::from(text.strip_prefix(r"\\?\").unwrap_or(text)) +} + +/// Checks that `result` failed with [`ErrorKind::Unsupported`] and a description that holds +/// `problem`. +fn assert_refused(result: nvx::Result, problem: &str) { + let error = result.unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert!(error.to_string().contains(problem), "{problem}: {error}"); +} + #[test] fn a_layered_launch_plans_its_blocks_and_boot_tokens_with_the_adapters_plan() { let directory = directory("layered-plan"); @@ -435,11 +501,355 @@ fn a_launch_records_what_it_launched() { assert_eq!(blockless.to_json().unwrap(), expected); } +#[test] +fn shares_read_their_host_path_their_target_and_their_access() { + let shares = [ + ("/data:/srv/data", "/data", "/srv/data", false), + ("/data:/srv/data:ro", "/data", "/srv/data", true), + (r"C:\data:/srv/data", r"C:\data", "/srv/data", false), + ("C:/data:/srv/data:ro", "C:/data", "/srv/data", true), + // The guest path starts at the last `:/`, and may hold other colons. + ("/a:/b:/srv/c:d", "/a:/b", "/srv/c:d", false), + // The planner refuses the guest's root. + ("/data:/", "/data", "/", false), + ]; + for (text, host, target, read_only) in shares { + let share = Share::parse(text).unwrap_or_else(|| panic!("{text:?}")); + assert_eq!(share.host(), Path::new(host), "{text:?}"); + assert_eq!(share.target(), target, "{text:?}"); + assert_eq!(share.is_read_only(), read_only, "{text:?}"); + } + // Text without a host path, or without a guest path that starts with `/`. + for text in ["/data", ":/srv/data", ":/srv/data:ro", "/data:srv/data", ""] { + assert_eq!(Share::parse(text), None, "{text:?}"); + } +} + +#[test] +fn a_launch_shares_host_paths_inside_the_authorized_directories() { + let directory = directory("layered-shares"); + let sources = sources(&directory); + let export = sharing(&sources).plan().unwrap().unwrap(); + + // The guest maps each share at its target, each before the mappings inside its target, from + // the export's directories and files, the outermost shares, in order. + let mapping = |source: &str, target: &str, read_only| HostMap { + source: source.to_owned(), + target: target.to_owned(), + read_only, + }; + let table = [ + mapping("0", "/srv/maps/dir", true), + mapping("1", "/srv/maps/file.txt", true), + mapping("2", "/srv/maps/out", false), + mapping("0/inner", "/srv/maps/dir/inner", false), + ]; + assert_eq!(export.guest_table(), table); + let dir = sources.join("dir"); + let children = export + .children + .iter() + .map(|child| (child.root.clone(), child.file, child.writable)) + .collect::>(); + let expected = [ + (canonical(&dir), false, true), + (canonical(&sources.join("file.txt")), true, false), + (canonical(&sources.join("out")), false, true), + ]; + assert_eq!(children, expected); + // OpenVMM hides the denied path, and lets the guest write only `dir/inner` in `dir`. + assert_eq!(export.children[0].denied, [canonical(&dir.join("secret"))]); + assert_eq!(export.children[0].write, [canonical(&dir.join("inner"))]); + + // 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"); + assert!(below.plan().unwrap().is_some()); + + // The launch announces the table in its root configuration, and attaches the export after + // the image. + let distro = image(directory.join("distro.erofs"), 3 * 512); + let mut launch = staged_launch(&distro, None); + launch.share(export.clone()).unwrap(); + assert_eq!(launch.export(), Some(&export)); + assert_eq!(launch.config.mapping_count, 4); + assert_eq!(launch.table(), table); + let config = String::from_utf8(launch.config.encode()).unwrap(); + assert!(config.contains("\nmapping-count=4\n"), "{config}"); + let prepared = launch.prepare().unwrap(); + let arguments = launch.openvmm_arguments( + &prepared, + Hypervisor::Kvm, + &directory.join("vmlinux"), + &directory.join("initramfs"), + "/run/control.sock", + &directory.join("outcome.json"), + ); + let exported = export + .openvmm_arguments() + .into_iter() + .map(OsString::from) + .collect::>(); + assert_eq!(exported[..2], ["--mount-aggregate", GUEST_EXPORT]); + assert_eq!(arguments[4..4 + exported.len()], exported[..]); + assert_eq!(arguments[4 + exported.len()], "--single-process"); + drop(prepared); + + // A launch that shares nothing announces no table, and attaches no export. + let mut nothing = HostFsSharing::default(); + nothing.add_root(sources.clone()); + assert_eq!(nothing.plan().unwrap(), None); + let plain = staged_launch(&distro, None); + assert!(plain.table().is_empty()); + assert_eq!(plain.export(), None); + assert_eq!(plain.config.mapping_count, 0); + + // The launch verifies the export before it prepares anything: a directory that replaced a + // shared file would grant all of itself. + fs::remove_file(sources.join("file.txt")).unwrap(); + fs::create_dir(sources.join("file.txt")).unwrap(); + let error = launch.prepare().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Other, "{error}"); + assert!( + error + .to_string() + .contains("no longer names the host object"), + "{error}" + ); + + // A table that the guest would refuse fails before the launch announces it. + let mut launch = staged_launch(&distro, None); + let mut too_many = export; + too_many.binds = vec![too_many.binds[0].clone(); MAX_HOST_MAPPINGS + 1]; + assert_refused(launch.share(too_many), "the mapping table does not encode"); + assert_eq!(launch.export(), None); + assert_eq!(launch.config.mapping_count, 0); +} + +#[test] +fn a_launch_refuses_shares_that_it_cannot_export() { + let directory = directory("layered-share-refusals"); + let sources = sources(&directory); + let outside = directory.join("outside"); + fs::create_dir_all(&outside).unwrap(); + let (dir, file, out) = ( + sources.join("dir"), + sources.join("file.txt"), + sources.join("out"), + ); + let refused = |change: &dyn Fn(&mut HostFsSharing), problem: &str| { + let mut request = sharing(&sources); + change(&mut request); + assert_refused(request.plan(), problem); + }; + let unauthorized = |path: &Path| { + format!( + "--share entry {} lies outside the host directories that --share-root authorizes", + path.display() + ) + }; + + // Each share must lie inside a directory that the adapter authorizes. + refused( + &|request| request.add_share(share(&outside, "/srv/outside", false)), + &unauthorized(&outside), + ); + refused( + &|request| request.set_roots(vec![dir.clone()]), + &unauthorized(&file), + ); + refused( + &|request| request.set_roots(Vec::new()), + &unauthorized(&dir), + ); + let missing = directory.join("missing"); + refused( + &|request| request.add_root(missing.clone()), + &format!("--share-root entry {} is not accessible", missing.display()), + ); + refused( + &|request| request.set_roots(vec![file.clone()]), + &format!("--share-root entry {} is not a directory", file.display()), + ); + refused( + &|request| request.add_share(share(&missing, "/srv/missing", true)), + &format!("--share entry {} is not accessible", missing.display()), + ); + + // The adapter's reserved trees are absolute and canonical guest paths, which no target takes + // or covers, as no target takes or covers those of the container root. + for (reserved, problem) in [ + ("opt", "is not absolute"), + ("/", "is not canonical, or is the root"), + ("/opt/", "is not canonical, or is the root"), + ] { + refused( + &|request| request.add_reserved(reserved), + &format!("--reserved entry {reserved:?} {problem}"), + ); + } + for (target, reserved) in [ + ("/opt/adapter/dir", "/opt/adapter"), + ("/opt", "/opt/adapter"), + ("/proc/dir", "/proc"), + ("/etc", "/etc"), + ("/run/nvx", "/run/nvx"), + ] { + refused( + &|request| retarget(request, 0, target), + &format!( + "--share entry {} has the guest target {target}, which would cover the guest's \ + {reserved}", + dir.display() + ), + ); + } + + // A read-only share inside a read-write one, which the host could not keep read-only (#552). + let sub = out.join("sub"); + refused( + &|request| request.add_share(share(&sub, "/srv/maps/out/sub", true)), + &format!( + "--share entry {} lies inside the --share entry {}, where the host cannot keep it \ + read-only", + sub.display(), + out.display() + ), + ); + // A denied path that a workload could create inside a share, which nothing would hide. + let unborn = dir.join("unborn"); + refused( + &|request| request.add_denied(unborn.clone()), + &format!("--deny entry {} does not exist", unborn.display()), + ); + + // A link inside an authorized directory to one outside it. + #[cfg(unix)] + { + let link = sources.join("link"); + std::os::unix::fs::symlink(&outside, &link).unwrap(); + refused( + &|request| request.add_share(share(&link, "/srv/link", false)), + &unauthorized(&link), + ); + } +} + +#[test] +fn the_host_program_refuses_shares_before_it_touches_the_store() { + let directory = directory("layered-share-command"); + // NVX's artifacts in a release's layout, which the launch takes, but which boot nothing. + let artifacts = directory.join("artifacts"); + let openvmm = if cfg!(windows) { + "bin/openvmm.exe" + } else { + "bin/openvmm" + }; + let mut sums = String::new(); + for name in [openvmm, "guest/vmlinux", "guest/initramfs.cpio.gz"] { + let path = artifacts.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, name).unwrap(); + sums.push_str(&format!("{} {name}\n", "0".repeat(64))); + } + fs::write(artifacts.join("SHA256SUMS"), sums).unwrap(); + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../SOURCE-MANIFEST.json"); + fs::copy(manifest, artifacts.join("SOURCE-MANIFEST.json")).unwrap(); + + let sources = sources(&directory); + let outside = directory.join("outside"); + fs::create_dir_all(&outside).unwrap(); + let distro = image(directory.join("distro.erofs"), 1024); + let store = directory.join("store"); + let shared = |path: &Path, access: &str| { + let mut value = path.as_os_str().to_owned(); + value.push(access); + value + }; + let root = sources.as_os_str().to_owned(); + let (dir, out) = (sources.join("dir"), sources.join("out")); + let cases: [(Vec, String); 4] = [ + ( + vec![ + "--share".into(), + shared(&outside, ":/srv/outside"), + "--share-root".into(), + root.clone(), + ], + format!( + "--share entry {} lies outside the host directories that --share-root authorizes", + outside.display() + ), + ), + ( + vec![ + "--share".into(), + shared(&dir, ":/opt/adapter/dir:ro"), + "--reserved".into(), + "/opt/adapter".into(), + "--share-root".into(), + root.clone(), + ], + "which would cover the guest's /opt/adapter".to_owned(), + ), + ( + vec![ + "--share".into(), + shared(&out, ":/srv/out"), + "--share".into(), + shared(&out.join("sub"), ":/srv/out/sub:ro"), + "--share-root".into(), + root.clone(), + ], + "where the host cannot keep it read-only".to_owned(), + ), + ( + vec![ + "--share".into(), + shared(&dir, ":/srv/dir"), + "--reserved".into(), + "opt".into(), + "--share-root".into(), + root, + ], + "--reserved entry \"opt\" is not absolute".to_owned(), + ), + ]; + for (options, problem) in cases { + let mut image = OsString::from("erofs:"); + image.push(&distro); + let output = Command::new(env!("CARGO_BIN_EXE_runtime-adapter-host")) + .arg("launch") + .arg(&store) + .arg("--guest-agent") + .arg(directory.join("agent")) + .args(["--hypervisor", if cfg!(windows) { "whp" } else { "kvm" }]) + .arg("--distro") + .arg(image) + .args([ + "--distro-identity", + &identity().to_string(), + "--upper", + "ram", + ]) + .args(&options) + .env(Artifacts::ENV_DIR, &artifacts) + .env_remove(STEP_HOOK) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1), "{options:?}: {output:?}"); + assert!(output.stdout.is_empty(), "{options:?}: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains(&problem), "{options:?}: {stderr}"); + assert!(!store.exists(), "{options:?}"); + } +} + #[test] 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); 24] = [ + let cases: [(&[&str], &str); 30] = [ ( &["--distro", "erofs:image", "--upper", "ram"], "--distro needs --distro-identity", @@ -530,13 +940,26 @@ fn the_host_program_refuses_options_that_do_not_make_a_command() { (&["mounts"], "unexpected argument \"mounts\" of launch"), (&["--hypervisor", "qemu"], "qemu"), (&["--distro"], "\"--distro\" needs a value"), + (&["--share", "a:/b"], "--share needs --distro"), + (&["--deny", "secret"], "--deny needs --share"), + (&["--reserved", "/opt/adapter"], "--reserved needs --share"), + (&["--share-root", "sources"], "--share-root needs --share"), + ( + &["--share", "a"], + "--share takes HOST:TARGET or HOST:TARGET:ro, with an absolute guest path TARGET, \ + not \"a\"", + ), + ( + &["--share", ":/b:ro"], + "--share takes HOST:TARGET or HOST:TARGET:ro", + ), ]; let mut commands = cases .iter() .map(|(options, problem)| ([&launch[..], options].concat(), *problem)) .collect::>(); let stage = ["stage", "store", "--distro", "erofs:image"]; - let stage_cases: [(&[&str], &str); 7] = [ + let stage_cases: [(&[&str], &str); 9] = [ (&[], "stage needs --distro-identity"), ( &["--distro-identity", uuid, "--upper", "ram"], @@ -558,6 +981,14 @@ fn the_host_program_refuses_options_that_do_not_make_a_command() { &["--guest-build", "build"], "unexpected option \"--guest-build\" of stage", ), + ( + &["--share", "a:/b"], + "unexpected option \"--share\" of stage", + ), + ( + &["--share-root", "sources"], + "unexpected option \"--share-root\" of stage", + ), (&["image"], "unexpected argument \"image\" of stage"), ]; commands.extend( @@ -586,6 +1017,10 @@ fn the_host_program_refuses_options_that_do_not_make_a_command() { vec!["reattach", "store", "--distro", "erofs:image"], "unexpected option \"--distro\" of reattach", ), + ( + vec!["reattach", "store", "--share", "a:/b"], + "unexpected option \"--share\" of reattach", + ), ]); for (arguments, problem) in commands { let output = Command::new(env!("CARGO_BIN_EXE_runtime-adapter-host")) diff --git a/tests/runtime-adapter/tests/vm_layered.rs b/tests/runtime-adapter/tests/vm_layered.rs index b86e56847..56d5b37f9 100644 --- a/tests/runtime-adapter/tests/vm_layered.rs +++ b/tests/runtime-adapter/tests/vm_layered.rs @@ -36,6 +36,7 @@ use std::time::Duration; use nvx::gpt::{Disk, Expected, ExpectedPartition}; use nvx::uuid::Uuid; +use runtime_adapter::GUEST_BUILD; use runtime_adapter::layered::RECORD_NAME; use runtime_adapter::root::{DEFAULT_RUNTIME, JournalPolicy, OverlayMode, RootConfig, RootStage}; use runtime_adapter::staged::STAGED_NAME; @@ -625,6 +626,12 @@ fn run(name: &str, scenario: Scenario) { }; let launched = sandbox.launch(&options, 0); assert_eq!(launched["root"], "ready"); + // The guest agent of the build that the host program ships assembled the root, applied the + // host's empty mapping table, and started the sandbox. + assert_eq!(launched["build"], GUEST_BUILD); + assert_eq!(launched["table"], "applied"); + assert_eq!(launched["mapped"], "0"); + assert_eq!(launched["sandbox"], "started"); let config = check_record(&sandbox, &root, &distro, &scratch); let vmm = sandbox .vmm() diff --git a/tests/runtime-adapter/tests/vm_restart.rs b/tests/runtime-adapter/tests/vm_restart.rs index 057d53711..a4e9661a5 100644 --- a/tests/runtime-adapter/tests/vm_restart.rs +++ b/tests/runtime-adapter/tests/vm_restart.rs @@ -1,6 +1,8 @@ //! The host program stages an image and launches a VM of the guest agent with a layered root //! through NVX's supervisor, and later processes of it reattach to the VM and stop it, as an -//! orchestrator does after it restarts, on a real hypervisor, through public APIs only. +//! orchestrator does after it restarts, on a real hypervisor, through public APIs only. A launch +//! that finds the guest agent of another build than the one that the host program ships tears the +//! VM down. //! //! The tests boot VMs, so they are ignored. They read what the `common` module lists, and boot its //! fixtures: NVX's Ubuntu EROFS image over a copy of the scratch template. @@ -16,12 +18,13 @@ use std::ffi::{OsStr, OsString}; use std::fs; use nvx::host::process::Process; +use runtime_adapter::GUEST_BUILD; use common::{Fixtures, Printed, Sandbox, epoch}; -/// Stages the fixtures' image over a copy of the scratch template in `sandbox`, launches a VM of -/// them, whose guest assembles the root, and returns what the launch printed. -fn launch(sandbox: &mut Sandbox) -> Printed { +/// Stages the fixtures' image over a copy of the scratch template in `sandbox`, and returns the +/// options that launch a VM of them. +fn stage(sandbox: &mut Sandbox) -> Vec { let fixtures = Fixtures::find(); let scratch = sandbox.root.join("scratch.ext4"); fs::copy(&fixtures.scratch, &scratch).unwrap(); @@ -29,20 +32,43 @@ fn launch(sandbox: &mut Sandbox) -> Printed { distro.push(&fixtures.distro); let mut upper = OsString::from("scratch:"); upper.push(&scratch); - let images = [ - OsStr::new("--distro"), - &distro, - OsStr::new("--distro-identity"), - OsStr::new(&fixtures.identity), - OsStr::new("--upper"), - &upper, + let images = vec![ + OsString::from("--distro"), + distro, + OsString::from("--distro-identity"), + OsString::from(&fixtures.identity), + OsString::from("--upper"), + upper, ]; - sandbox.run("stage", &images, 0); - let launched = sandbox.launch(&images, 0); - assert_eq!(launched["root"], "ready"); + sandbox.run("stage", &options(&images), 0); + images +} + +/// Returns `images` as the options of a command. +fn options(images: &[OsString]) -> Vec<&OsStr> { + images.iter().map(OsString::as_os_str).collect() +} + +/// Stages the fixtures' image over a copy of the scratch template in `sandbox`, launches a VM of +/// them, whose guest agent assembles the root, and starts the sandbox, and returns what the launch +/// printed. +fn launch(sandbox: &mut Sandbox) -> Printed { + let images = stage(sandbox); + let launched = sandbox.launch(&options(&images), 0); + assert_started(&launched); launched } +/// Checks that a launch printed that the guest agent of the build that the host program ships +/// assembled the root, and that the guest started the sandbox, without mappings. +fn assert_started(launched: &Printed) { + assert_eq!(launched["root"], "ready"); + assert_eq!(launched["build"], GUEST_BUILD); + assert_eq!(launched["table"], "applied"); + assert_eq!(launched["mapped"], "0"); + assert_eq!(launched["sandbox"], "started"); +} + /// Checks that a command stopped the VM of `vmm` that `launched` printed, with the guest agent's /// confirmation, after it attached a session in an epoch after `earlier`'s: the VM exited with /// a successful outcome, and no runtime record names it. @@ -117,3 +143,38 @@ fn a_host_program_of_another_runtime_finds_the_vm_mismatched_and_signals_nothing let stopped = sandbox.run("stop", &[], 0); assert_stopped(&sandbox, &stopped, &launched, &launched, &vmm); } + +#[test] +#[ignore = "boots a VM; see the module documentation for what it needs"] +fn a_launch_tears_down_a_vm_whose_guest_agent_is_of_another_build() { + let mut sandbox = Sandbox::new("build"); + let images = stage(&mut sandbox); + + // The host program ships another build of the guest agent than the one that it boots, which + // assembles the root, and reports its own build. + let mut another = options(&images); + another.extend([OsStr::new("--guest-build"), OsStr::new("another-build")]); + let launched = sandbox.launch(&another, 1); + assert_eq!(launched["root"], "ready"); + assert_eq!(launched["build"], GUEST_BUILD); + let stderr = sandbox.stderr(); + let problem = + format!("the guest agent is of build {GUEST_BUILD}, not of the host's build another-build"); + assert!(stderr.contains(&problem), "{stderr}"); + // The launch sent it neither the mapping table nor `Start`, and ended the VM: no runtime + // record names it. + for key in ["table", "mapped", "sandbox"] { + assert!(!launched.contains_key(key), "{launched:?}"); + } + assert_eq!(sandbox.record(), None); + + // The VM released the scratch image as it ended, so the next launch, of the build that the + // host program ships, claims it, and starts the sandbox. + let launched = sandbox.launch(&options(&images), 0); + assert_started(&launched); + let vmm = sandbox + .vmm() + .expect("the VM runs after its launcher exited"); + let stopped = sandbox.run("stop", &[], 0); + assert_stopped(&sandbox, &stopped, &launched, &launched, &vmm); +}