From 77e1660968352bdaed90301bb9c8231eaa425d6f Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Sat, 24 Oct 2026 13:57:56 -0700 Subject: [PATCH] nvx-rs: lower adapter plans into the plans of template captures A template is a capture of a microVM that consumed only its invariant configuration, for clones that each bind their own image, upper, and export when they are restored. Before a store captures one, the host needs the exact VM and options of that capture: OpenVMM refuses a platform-tier capture whose kernel command line holds a token outside its platform allowlist, and a template must hold no tenant data. Add nvx::host::template::TemplatePlan, which lowers an adapter's SandboxPlan without I/O into: - the placeholder distro, bound through its DistroBinding, whose length is the capacity of every clone's image, at an absolute path that OpenVMM's block device options hold whole, and that it attaches as a raw image, over a dormant scratch device, a sparse file in the capture's transaction directory; - the dormant filesystem slot, and no NIC or one fixed NIC under the template's policy, which may allow neither ingress nor host-loopback access both ways, since OpenVMM publishes the guest's listeners through them as live host attachments, which no snapshot holds; - TemplateResources: every processor online at boot, and RAM with a --memory-capacity, in blocks of 128 MiB; - the kernel command line nvx_sandbox=1, the one token that the host adds to those that OpenVMM writes itself; - the capture's options: --snapshot-tier platform, a --snapshot-destination in the transaction directory, under the name of a generation's snapshot in the template store, and --snapshot-block-identity generation with the capture's StorageGeneration, the bound-block storage generation, so that the capture hashes no block device. The tests check each token of a template's command line with OpenVMM's own allowlist, compiled from verbatim excerpts of its source in tests/vectors/openvmm/, which the ignored test of tests/openvmm_vectors.rs compares with the OpenVMM submodule. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 32eea669-a52c-4a53-87fa-521c498537e5 --- .github/workflows/ci.yml | 9 +- doc/ci.md | 9 +- nvx-rs/README.md | 2 +- nvx-rs/src/host/mod.rs | 6 +- nvx-rs/src/host/sandbox/mod.rs | 4 +- nvx-rs/src/host/template/mod.rs | 2 + nvx-rs/src/host/template/plan.rs | 453 +++++++++ nvx-rs/src/host/template/plan/tests.rs | 942 ++++++++++++++++++ nvx-rs/tests/openvmm_vectors.rs | 55 +- nvx-rs/tests/vectors/README.md | 35 +- .../microvm_control_tty_command_line.rs | 2 + ...latform_command_line_token_is_invariant.rs | 26 + 12 files changed, 1513 insertions(+), 32 deletions(-) create mode 100644 nvx-rs/src/host/template/plan.rs create mode 100644 nvx-rs/src/host/template/plan/tests.rs create mode 100644 nvx-rs/tests/vectors/openvmm/microvm_control_tty_command_line.rs create mode 100644 nvx-rs/tests/vectors/openvmm/platform_command_line_token_is_invariant.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 482a3d133..88f3dc519 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,10 +137,11 @@ jobs: - name: Validate NVX CLI uses: ./.github/actions/validate-nvx - # nvx-rs's protocol tests read copies of OpenVMM's control-session - # protocol vectors, because the nvx-rs job cannot check out OpenVMM. - # This job can, so it compares the copies with the submodule's files, - # and nvx::contract's versions of OpenVMM's contracts with OpenVMM's. + # nvx-rs's protocol and template tests read copies of OpenVMM's + # control-session protocol vectors and of the lines of its platform + # allowlist, because the nvx-rs job cannot check out OpenVMM. This job + # can, so it compares the copies with the submodule's files, and + # nvx::contract's versions of OpenVMM's contracts with OpenVMM's. - name: Compare OpenVMM's protocol vectors shell: bash run: >- diff --git a/doc/ci.md b/doc/ci.md index b58abb28d..5dad64c40 100644 --- a/doc/ci.md +++ b/doc/ci.md @@ -573,10 +573,11 @@ for the curl shim test and the `nvx-rs` build. Each job first builds the `nvx-rs` executable that the microVM tests run, tests with it, and hands it to the microVM test and platform jobs of its operating system, as described above. Each also runs the ignored `nvx-rs` test `tests/openvmm_vectors.rs`, which -compares the submodule's control-session protocol vectors with the copies that -the protocol tests read in the `nvx-rs` job, which cannot check out OpenVMM, -and the versions of OpenVMM's snapshot format, saved-state schema, time ABI, -and restore packet with those of `nvx::contract`; +compares the submodule's control-session protocol vectors, and the lines of its +platform allowlist of kernel command-line tokens, with the copies that the +protocol and template tests read in the `nvx-rs` job, which cannot check out +OpenVMM, and the versions of OpenVMM's snapshot format, saved-state schema, +time ABI, and restore packet with those of `nvx::contract`; see [OpenVMM's vectors](../nvx-rs/tests/vectors/README.md#openvmms-vectors). The ignored test `tests/openvmm_cpu_profiles.rs` likewise compares the submodule's built-in CPU profiles with the copy of their identities from which diff --git a/nvx-rs/README.md b/nvx-rs/README.md index f78b3f451..1960b71ff 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -97,7 +97,7 @@ nvx-rs/ | `nvx::host::nvxs` | `HostConnection`: the host's role of the NVXS session over a platform transport, on which `nvx::host::session` and other host applications run their protocols. It attaches with the launch capability, waiting while the broker asks it to, sends application bytes in as many DATA records as they take, and returns each DATA payload from the guest, with deadlines; a receive that times out keeps a partial record | | `nvx::host::process` | Verified process references. A `ProcessRef` names one process by its ID, its start time, and the `BootId` of the host boot during which it started. `Process::open` checks a reference and holds its process through a pidfd on Linux or a process handle on Windows, whether or not the caller started it, so that waits sleep until the process exits and termination never reaches a process that reuses its ID. A reference from an earlier boot names an exited process, and a process whose identity this user cannot check, such as another user's, makes `Process::open` fail rather than report an exit. Linux hosts without pidfds poll the reference instead, and cannot terminate the process | | `nvx::host::supervisor` | The supervisor of a sandbox's VMM, beneath the CLI, `Instance`, and external orchestrators, which keep the configuration, the authorization, and the application protocol. `launch` runs the store's launch steps under the sandbox's lock, as a launch whose `LaunchId` the caller reserved beforehand, a fresh one for each launch, or that a clone's lease reserves: it writes the journal's marker, which names the launch, spawns OpenVMM with the claimed log, records its `ProcessRef` right after, and returns a `Starting` launch that the caller commits once the VMM serves its control endpoint, or abandons. A launch that fails never leaves an unrecorded VMM running. `VmmCommand::inherit` lists the descriptors (Linux) or handles (Windows) that the VMM inherits under their numbers, besides its standard streams, and it inherits nothing else of the caller's: on Linux, they must be close-on-exec, a flag that only the VMM's own process clears; on Windows, they join the VMM's explicit handle list. `Vmm::stop` runs one graceful step of the caller's and waits for the VMM's exit event until a deadline, and takes a bounded forced step through the VMM's verified identity only on request. `Vmm::terminate` takes that forced step alone, within the caller's bound, for a VM whose guest the caller must not ask anything, such as one of another runtime identity. `reattach` recovers an interrupted launch, and holds the recorded VMM again after the orchestrator restarts within the same host boot: `Live`, `Mismatched` with another runtime identity, `Exited`, or `Indeterminate`. A clone's launch takes its locks in one order, the template store's, then the sandbox's, which guards its clone binding and launch journal, then the scratch image's claim, and holds none for the VM's lifetime; its lease lasts through crashes and reattaches, until a release ends it once a stop, a termination, or a reattach has verified the VM's exit | -| `nvx::host::template` | The metadata of templates, captures of a microVM from which independent clones are restored. `TemplateManifest::read` reads the `manifest.bin` that OpenVMM wrote for a capture, at most `MAX_MANIFEST_LEN` bytes of a regular file that is not a link or other reparse point, and refuses another snapshot format version, an unknown, mistyped, or repeated field, truncated bytes, and a capture that is not a template's: a platform-tier capture for clones, with a read-only `distro` whose identity is unbound, a fresh scratch, and the dormant filesystem slot. `CaptureClass` holds the invariant inputs of a capture, known before it: the runtime, the guest's build, the backend, the requested CPU profile, the processors and RAM, the geometries of the disks, the network, and the adapter's other `Invariants`. `TemplateKey::new` checks that the capture made the machine that its class requested, and derives the template's compatibility key, from metadata alone: the runtime and contract versions through which its clones are restored, and the backend, CPU profile and effective CPUID, topology, rates, resources, kernel command line, disks, and devices of its machine. A capture's own metadata, such as its time anchor and host, stays out of the key. A key and a class each have one canonical, versioned encoding, whose domain-separated SHA-256 digest is its identity, a `KeyIdentity`, which converts to and from the `nvx::activation::Identity` by which a clone's activation record names its template's key, or a `ClassIdentity`; the [conformance vectors](tests/vectors/README.md) of version 1, `tests/vectors/template-key-v1.txt`, pin the key's encoding and identity. `Store` keeps the generations of templates that a host published, apart from every sandbox's store, at a location that the adapter chooses: a private directory with an identity of its own, a `StoreId`, the immutable payload of each generation, its key's canonical encoding beside the snapshot that OpenVMM published, and the versioned `Index` of them, which lists each generation with the identities of its key and capture class and its own metadata, such as its time contract. `Store::create` creates a store, or opens the one that its directory holds, crash-safely and once between concurrent creations, and refuses a directory with foreign entries; `Store::open` opens one. Its records are JSON in one format, replaced atomically, and records of another format fail rather than being migrated. Its directories must be private to the current user, and it refuses links and, on Windows, other reparse points among its directories and files. Four namespaces stay apart: the `nvx::activation::StoreGeneration`, the host's publication ID, which the index assigns in the order of publication and never twice, and by which a clone's activation record names its template; the bound-block `StorageGeneration` of a capture, which belongs to the storage owner; the time lineage counter of the time contract; and the VM generation ID, which the store never records. `TimeContract::eligibility` decides, apart from compatibility, whether a clone may restore a capture at a host's time, a `HostTime`, which `HostTime::now` samples as OpenVMM does: within the boot of the capture, by the host's monotonic clock, and otherwise by UTC, the downtime must not be negative, and the interval by which a restore advances the clone's VM time at most `MAX_DOWNTIME_NS`, 30 days. `Store::select` selects the latest eligible generation of a key that no tombstone quarantines. `Store::acquire` leases a generation to a clone in one transaction, under the store's lock and then the sandbox's: it checks the generation's eligibility, writes the lease's record in the store and the clone binding, `clone.json`, beside the sandbox's launch journal, and returns the lease with the sandbox's lock, the only one under which the caller launches the clone, once, as the launch that the lease reserves, `Lease::launch`. A lease never expires: `Store::release` ends it once the sandbox records no VM and no launch. `Store::tombstone` quarantines a generation, which takes no new lease while its leases keep working, and `Store::retire` deletes it once its leases have drained, crash-safely, removing the records of leases that no clone binding names any more | +| `nvx::host::template` | The metadata of templates, captures of a microVM from which independent clones are restored. `TemplatePlan::new` lowers an adapter's `SandboxPlan` into the plan of a template's capture, without I/O: the placeholder `distro`, which its `DistroBinding` binds, and whose length is the capacity of every clone's image, over a dormant scratch device, the sparse file `SCRATCH_NAME` in the capture's transaction directory, with the dormant filesystem slot, and no NIC or one under the template's policy, which allows neither ingress nor host-loopback access both ways; `TemplateResources`, with every processor online at boot, and RAM and a `--memory-capacity` in blocks of `MEMORY_BLOCK_SIZE`; the kernel command line `nvx_sandbox=1`, so that the capture's whole command line holds tokens of OpenVMM's platform allowlist alone, and no tenant data, which the tests check with a copy of the allowlist's source; and the capture's options, `--snapshot-tier platform`, a `--snapshot-destination`, `SNAPSHOT_NAME` in the transaction directory, and `--snapshot-block-identity generation` with the capture's bound-block `StorageGeneration`, so that the capture hashes no block device. `TemplateManifest::read` reads the `manifest.bin` that OpenVMM wrote for a capture, at most `MAX_MANIFEST_LEN` bytes of a regular file that is not a link or other reparse point, and refuses another snapshot format version, an unknown, mistyped, or repeated field, truncated bytes, and a capture that is not a template's: a platform-tier capture for clones, with a read-only `distro` whose identity is unbound, a fresh scratch, and the dormant filesystem slot. `CaptureClass` holds the invariant inputs of a capture, known before it: the runtime, the guest's build, the backend, the requested CPU profile, the processors and RAM, the geometries of the disks, the network, and the adapter's other `Invariants`. `TemplateKey::new` checks that the capture made the machine that its class requested, and derives the template's compatibility key, from metadata alone: the runtime and contract versions through which its clones are restored, and the backend, CPU profile and effective CPUID, topology, rates, resources, kernel command line, disks, and devices of its machine. A capture's own metadata, such as its time anchor and host, stays out of the key. A key and a class each have one canonical, versioned encoding, whose domain-separated SHA-256 digest is its identity, a `KeyIdentity`, which converts to and from the `nvx::activation::Identity` by which a clone's activation record names its template's key, or a `ClassIdentity`; the [conformance vectors](tests/vectors/README.md) of version 1, `tests/vectors/template-key-v1.txt`, pin the key's encoding and identity. `Store` keeps the generations of templates that a host published, apart from every sandbox's store, at a location that the adapter chooses: a private directory with an identity of its own, a `StoreId`, the immutable payload of each generation, its key's canonical encoding beside the snapshot that OpenVMM published, and the versioned `Index` of them, which lists each generation with the identities of its key and capture class and its own metadata, such as its time contract. `Store::create` creates a store, or opens the one that its directory holds, crash-safely and once between concurrent creations, and refuses a directory with foreign entries; `Store::open` opens one. Its records are JSON in one format, replaced atomically, and records of another format fail rather than being migrated. Its directories must be private to the current user, and it refuses links and, on Windows, other reparse points among its directories and files. Four namespaces stay apart: the `nvx::activation::StoreGeneration`, the host's publication ID, which the index assigns in the order of publication and never twice, and by which a clone's activation record names its template; the bound-block `StorageGeneration` of a capture, which belongs to the storage owner; the time lineage counter of the time contract; and the VM generation ID, which the store never records. `TimeContract::eligibility` decides, apart from compatibility, whether a clone may restore a capture at a host's time, a `HostTime`, which `HostTime::now` samples as OpenVMM does: within the boot of the capture, by the host's monotonic clock, and otherwise by UTC, the downtime must not be negative, and the interval by which a restore advances the clone's VM time at most `MAX_DOWNTIME_NS`, 30 days. `Store::select` selects the latest eligible generation of a key that no tombstone quarantines. `Store::acquire` leases a generation to a clone in one transaction, under the store's lock and then the sandbox's: it checks the generation's eligibility, writes the lease's record in the store and the clone binding, `clone.json`, beside the sandbox's launch journal, and returns the lease with the sandbox's lock, the only one under which the caller launches the clone, once, as the launch that the lease reserves, `Lease::launch`. A lease never expires: `Store::release` ends it once the sandbox records no VM and no launch. `Store::tombstone` quarantines a generation, which takes no new lease while its leases keep working, and `Store::retire` deletes it once its leases have drained, crash-safely, removing the records of leases that no clone binding names any more | | `nvx::host::seal` | `FileSeal`: what the host reports about an open file from its metadata alone, without reading it: the platform scheme, the device or volume serial number, the file ID, the kind, the length, the modification time, and on Linux the change time, with a versioned serde form. A seal detects accidental change, not tampering; see [Distro bindings](#distro-bindings) | | `nvx::host::session`, `platform`, `report` | The authenticated control session, which carries NVX's control protocol, `NVXC`, one frame per DATA record of a `HostConnection`; Linux and Windows primitives; and OpenVMM's outcome report | | `nvx::host::sandbox` | The launch profile of `nvx-rs sandbox`, with native input validation: `SandboxLaunch`, with the distro `SandboxLayer`, an EROFS layer or a GPT disk, the one layer that a sandbox takes, the ext4 scratch disk, the entrypoint, arguments, hostname, workload identity, and `SandboxSettings` resource limits (`Option` for memory and `Option` for processes), and the `SandboxPlan` that it makes of them. `plan`: `SandboxPlan`, the typed plan of a cold layered launch, which is persistable in a versioned serde form: one read-only `distro` block device, an EROFS filesystem or a GPT disk with the identity that the guest expects of it, if any, an upper, the ext4 scratch disk or RAM, the launch settings, the `ExportPlan` of its live shares, and optionally a `SandboxNetwork`, the guest network and its typed `NetworkPolicy`, whose proxy's URL as the guest sees it the plan reports. The plan of an adapter that boots its own guest, `SandboxPlan::adapter`, holds no launch settings and no export plan. A plan knows only the `distro` and `scratch` `BlockRole`s, and gives, for every combination, its `--microvm-sandbox-block` arguments, with `--microvm-workload-identity` when it has launch settings, with no scratch block device for a RAM upper, and those of its network, which `nvx::host::network` lowers, and its kernel command line, with the boot tokens of `nvx::protocol::root`, within the 1024-byte budget that OpenVMM leaves it, refusing one whose root tokens the guest's parser refuses. Both deliver the live shares as the managed lifecycle does: the arguments export them at `nvx::host::filesystem::GUEST_EXPORT`, the command line announces the number of their mappings with `nvx_maps=`, and `mapping_table` gives the ordered table that `start` sends once the guest agent is ready; the CLI's one-shot `run` still renders them as boot tokens. An adapter's plan renders the caller's tokens before those of its root, and refuses tokens that its root or OpenVMM's shares own. `mount`: `SandboxMount` requests of live shares of host directories and files, their guest targets and the reserved ones, their access policies and owner, and the checks of their host paths; `plan_mounts`, which plans them through `nvx::host::filesystem` for the container root, and `boot_shares`, which renders a plan as the OpenVMM arguments and boot tokens of one share or of the aggregate of several, as the one-shot lifecycle delivers them. `options`: the checks that `nvx-rs sandbox` and `nvx-rs run` apply to their options: the attribution of `--mount-deny`, `--mount-allow`, and `--mount-write` to shares, the systemd entrypoint check and the explicit verification of the distro layer, the targets of `nvx-rs run` shares, and the managed exec environment; see [Sandbox input contract](#sandbox-input-contract). `distro`: the `DistroBinding` of a distro layer, an EROFS layer or a GPT disk, which explicit verification makes or a staging caller imports, and its check from metadata alone, which returns the layer open; see [Distro bindings](#distro-bindings). `scratch`: the `ScratchBinding` of a scratch image, its identity, kind, and length, and its check from metadata alone, which claims the image for one sandbox at a time and returns it open. `prepared`: `PreparedLaunch`, the launch of a plan with the checked files of its block devices, which renders the plan's OpenVMM arguments and kernel command line, naming the checked files as `/proc/self/fd/N` on Linux and by their final paths on Windows, and lists the descriptors that the VMM inherits through `VmmCommand::inherit`; see [Checked handoff](#checked-handoff) | diff --git a/nvx-rs/src/host/mod.rs b/nvx-rs/src/host/mod.rs index 12bf21753..6fa1f53f1 100644 --- a/nvx-rs/src/host/mod.rs +++ b/nvx-rs/src/host/mod.rs @@ -23,8 +23,10 @@ //! - [`template`] holds the metadata of templates, captures from which clones are restored: the //! compatibility key of a template, [`TemplateKey`](template::TemplateKey), which it derives //! from the [`CaptureClass`](template::CaptureClass) of the capture and the capture's -//! [`TemplateManifest`](template::TemplateManifest), and the [`Store`](template::Store) of the -//! generations of templates that a host published, which leases them to clones. +//! [`TemplateManifest`](template::TemplateManifest), the [`Store`](template::Store) of the +//! generations of templates that a host published, which leases them to clones, and the plan +//! of a template's capture, [`TemplatePlan`](template::TemplatePlan), which it lowers from an +//! adapter's plan. //! - [`session`] speaks NVX's control protocol, `NVXC`, over [`nvxs`], which runs the host's role //! of the NVXS session for it and for any other application protocol, over a platform //! transport. [`platform`] holds the primitives of Linux and Windows hosts. Other hosts compile diff --git a/nvx-rs/src/host/sandbox/mod.rs b/nvx-rs/src/host/sandbox/mod.rs index 947f97ef5..5c98ac18e 100644 --- a/nvx-rs/src/host/sandbox/mod.rs +++ b/nvx-rs/src/host/sandbox/mod.rs @@ -156,7 +156,7 @@ pub fn require_file(path: &Path, description: &str) -> Result<()> { /// Refuses a disk path that OpenVMM's `--microvm-sandbox-block` value could not hold: one with /// a comma or semicolon. The message shows `path` as `shown`. -fn reject_disk_path(path: &Path, shown: &str) -> Result<()> { +pub(crate) fn reject_disk_path(path: &Path, shown: &str) -> Result<()> { if path.to_string_lossy().contains([',', ';']) { return Err(refused(format!( "disk paths containing commas or semicolons are unsupported: {shown}" @@ -174,7 +174,7 @@ const CONTAINER_EXTENSIONS: [&str; 4] = ["vhd", "vhdx", "vmgs", "iso"]; /// as a raw image: if the name of `path` has an extension of [`CONTAINER_EXTENSIONS`]. The /// extension is the text after the name's last dot, unless that dot starts the name, and compares /// case-sensitively, as OpenVMM reads and compares it. -fn reject_container_path(path: &Path, shown: &str) -> Result<()> { +pub(crate) fn reject_container_path(path: &Path, shown: &str) -> Result<()> { let name = path.file_name().map(|name| name.to_string_lossy()); let extension = match name.as_deref().unwrap_or_default().rsplit_once('.') { Some((stem, extension)) if !stem.is_empty() => extension, diff --git a/nvx-rs/src/host/template/mod.rs b/nvx-rs/src/host/template/mod.rs index f249b05fd..4a1973ad7 100644 --- a/nvx-rs/src/host/template/mod.rs +++ b/nvx-rs/src/host/template/mod.rs @@ -51,6 +51,7 @@ mod canonical; mod eligibility; mod key; mod manifest; +mod plan; mod store; #[cfg(test)] mod test_support; @@ -61,6 +62,7 @@ pub use self::key::{CaptureClass, ClassIdentity, Invariants, KeyIdentity, Templa pub use self::manifest::{ BlockGeometry, HostClock, MANIFEST_NAME, MAX_MANIFEST_LEN, TemplateManifest, TimeContract, }; +pub use self::plan::{MEMORY_BLOCK_SIZE, SCRATCH_NAME, TemplatePlan, TemplateResources}; pub use self::store::{ Acquired, GENERATIONS_NAME, Generation, INDEX_LOCK_NAME, INDEX_NAME, Index, KEY_NAME, LEASES_NAME, Lease, LeaseId, MAX_GENERATIONS, Retirement, SNAPSHOT_NAME, STORE_FORMAT, diff --git a/nvx-rs/src/host/template/plan.rs b/nvx-rs/src/host/template/plan.rs new file mode 100644 index 000000000..b3cbad1a9 --- /dev/null +++ b/nvx-rs/src/host/template/plan.rs @@ -0,0 +1,453 @@ +//! The plan of a template's capture, [`TemplatePlan`]: the VM that a template's capture boots, +//! lowered from an adapter's plan, and the options with which OpenVMM captures it. + +use std::path::{Path, PathBuf}; + +use super::manifest::PROCESSOR_COUNTS; +use super::store::{SNAPSHOT_NAME, StorageGeneration}; +use crate::error::{Error, Result}; +use crate::host::network::{Access, HostLoopback, NetworkPolicy}; +use crate::host::paths; +use crate::host::sandbox::distro::DistroBinding; +use crate::host::sandbox::plan::bound_image; +use crate::host::sandbox::{SandboxPlan, SandboxUpper, reject_container_path, reject_disk_path}; + +/// Bytes in a mebibyte, the unit in which the plan writes OpenVMM's `--memory` and +/// `--memory-capacity`. +const MIB: u64 = 1024 * 1024; + +/// The size of the blocks of a microVM's RAM, in bytes: 128 MiB, as OpenVMM's microVM profile +/// fixes it. A template's RAM and its RAM capacity are whole blocks, and a restore expands the RAM +/// by whole blocks. +pub const MEMORY_BLOCK_SIZE: u64 = 128 * MIB; + +/// The name of the dormant scratch device's file in a capture's transaction directory: a sparse +/// file of the template's scratch length, which the template does not keep. +pub const SCRATCH_NAME: &str = "scratch.raw"; + +/// The kernel command line of a template's VM. +const COMMAND_LINE: &str = "nvx_sandbox=1"; + +/// The snapshot tier of a template's capture: OpenVMM's clone point of the invariant +/// configuration alone. +const TIER: &str = "platform"; + +/// The block identity of a template's capture: the bound-block storage generation, which OpenVMM +/// takes in place of a digest of each block's content. +const BLOCK_IDENTITY: &str = "generation"; + +/// The logical block size that OpenVMM presents for a sandbox block device, of which the device's +/// length is a multiple. +const LOGICAL_BLOCK_SIZE: u64 = 512; + +/// The resources of a template's VM: its processors, all online at boot, and its RAM, with the +/// capacity to which a restore may expand it. +/// +/// A template boots with every one of its processors online, so its clones restore with all of +/// them, and it serves clones of its processor count alone. [`TemplatePlan::new`] checks the +/// resources. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct TemplateResources { + /// The processors, all online at boot: 1, 2, 4, or 8. + pub processors: u32, + /// The RAM at boot, in bytes: a nonzero multiple of [`MEMORY_BLOCK_SIZE`]. + pub memory: u64, + /// The RAM capacity, in bytes, to which a restore may expand the RAM: a multiple of + /// [`MEMORY_BLOCK_SIZE`] of at least [`memory`](Self::memory). + pub memory_capacity: u64, +} + +/// The plan of a template's capture: the VM that the capture boots, lowered from an adapter's +/// plan, and the options with which OpenVMM captures it, which +/// [`openvmm_arguments`](Self::openvmm_arguments) and +/// [`kernel_command_line`](Self::kernel_command_line) render. +/// +/// A template's VM boots its invariant configuration alone, for clones, each of which binds its +/// own image, upper, and export when it is restored. [`new`](Self::new) lowers an adapter's plan, +/// which [`SandboxPlan::adapter`] makes, into that VM: +/// +/// - The plan's `distro` is the placeholder: a trusted platform artifact, which its +/// [`DistroBinding`] binds, and whose length is the capacity of every clone's image. +/// - A dormant scratch device takes the place of the plan's upper, which is a clone's: a sparse +/// file of the template's scratch length, [`SCRATCH_NAME`] in the capture's transaction +/// directory, which sets the device's length and block sizes, and which the template does not +/// keep. +/// - The filesystem slot, which every cold boot exposes, stays dormant: the plan exports nothing. +/// - The plan's network gives the template its fixed NIC, with its policy, or none, for a +/// template whose clones have no network. The policy allows neither ingress nor host-loopback +/// access both ways, which its forwards need: through them, OpenVMM publishes the guest's +/// listeners as live host attachments, which no snapshot holds. +/// - Its [`TemplateResources`]: its processors, all online at boot, and its RAM, with the capacity +/// to which a restore may expand it, OpenVMM's `--memory-capacity`. +/// +/// # The kernel command line +/// +/// OpenVMM keeps the effective kernel command line of a template, which its +/// [key](super::TemplateKey) holds and every clone restores, and refuses a capture whose command +/// line holds a token outside the allowlist of its platform tier. The template's own command line +/// is `nvx_sandbox=1`, with which the guest's init hands the VM to its sandbox control. OpenVMM +/// writes the other tokens itself, each on the allowlist: the early console, console, reboot, and +/// panic settings, `nr_cpus=` with the processors, `nvx_snapshot_tier=platform`, the virtio +/// devices and the control tty that the guest discovers, and the NIC's addresses. The arguments +/// name no workload identity, lifecycle, share, or published listener, for which OpenVMM would +/// write other tokens. The command line therefore holds no image identity, hostname, share, +/// proxy, entrypoint, or other tenant data. +/// +/// # The capture +/// +/// The capture's options are `--snapshot-tier platform`; `--snapshot-destination`, the +/// destination [`SNAPSHOT_NAME`] in the capture's transaction directory, by which a generation of +/// a [`Store`](super::Store) names its snapshot too; and `--snapshot-block-identity generation`, +/// with the capture's [`StorageGeneration`] as `--snapshot-generation-id`, so that the capture +/// hashes no block device. The template keeps no storage generation: OpenVMM unbinds its +/// `distro`, and its scratch is fresh. +/// +/// The transaction directory is the capture's own, and the destination must not exist when the +/// capture launches. OpenVMM backs the VM's RAM with a file that it creates beside the +/// destination, and publishes the snapshot at the destination, so every file of the capture is in +/// the transaction directory. +/// +/// The caller creates the transaction directory and the sparse scratch file, checks the +/// placeholder against its binding, as [`DistroBinding::check`] does, and adds its own arguments, +/// such as the hypervisor, the kernel and the initramfs, the CPU profile, and the consoles. It +/// passes [`kernel_command_line`](Self::kernel_command_line) as OpenVMM's `--cmdline`, alone, and +/// names no workload identity, lifecycle, share, or RAM backing file of its own: +/// +/// ```no_run +/// use std::path::Path; +/// +/// use nvx::host::sandbox::distro::DistroBinding; +/// use nvx::host::sandbox::SandboxPlan; +/// use nvx::host::template::{MEMORY_BLOCK_SIZE, StorageGeneration, TemplatePlan, TemplateResources}; +/// +/// # fn capture( +/// # adapter: &SandboxPlan, +/// # binding: &DistroBinding, +/// # transaction: &Path, +/// # generation: StorageGeneration, +/// # ) -> nvx::Result<()> { +/// let resources = TemplateResources { +/// processors: 2, +/// memory: 4 * MEMORY_BLOCK_SIZE, +/// memory_capacity: 16 * MEMORY_BLOCK_SIZE, +/// }; +/// let template = +/// TemplatePlan::new(adapter, binding, 64 << 20, resources, transaction, generation)?; +/// let mut arguments = template.openvmm_arguments()?; +/// arguments.extend(["--cmdline".to_owned(), template.kernel_command_line().to_owned()]); +/// // ... the caller's own arguments: the hypervisor, the kernel and the initramfs, the CPU +/// // profile, and the consoles ... +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TemplatePlan { + /// The capture's devices: an adapter's plan of the placeholder over the dormant scratch device, + /// with the NIC, if any. + plan: SandboxPlan, + /// The binding of the placeholder. + binding: DistroBinding, + /// The length of the dormant scratch device, in bytes. + scratch_length: u64, + /// The processors and RAM. + resources: TemplateResources, + /// The capture's transaction directory, spelled with its components alone. + transaction: PathBuf, + /// The snapshot's destination in the transaction directory. + destination: PathBuf, + /// The bound-block storage generation of the capture. + generation: StorageGeneration, +} + +impl TemplatePlan { + /// Lowers `adapter`, an adapter's plan, which [`SandboxPlan::adapter`] makes, into the plan of + /// a template's capture: of its placeholder, which `binding` binds, over a dormant scratch + /// device of `scratch_length` bytes, with its network, the processors and RAM of `resources`, + /// the capture's transaction directory `transaction`, spelled with its components alone, as + /// [`Path::components`] reads them, and its bound-block storage generation `generation`, + /// which is apart from the other [namespaces](super::Store#four-namespaces) of a template's + /// generations. + /// + /// The plan's upper is no part of the template, which attaches its dormant scratch device in + /// the upper's place, whatever the upper. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), checking in this + /// order: a plan of the sandbox profile, with workload settings; a plan whose image is not the + /// format and identity that `binding` names, as for a binding whose identity is not a UUID in + /// its canonical text; a placeholder whose path is relative, which OpenVMM would resolve + /// against the directory in which it runs, holds a comma or a semicolon, which OpenVMM's block + /// device options would split, or whose name ends in `.vhd`, `.vhdx`, `.vmgs`, or `.iso`, + /// which OpenVMM would not attach as a raw image; a placeholder whose binding records + /// a length, or a scratch length, that is not a nonzero multiple of the 512 bytes of the + /// logical blocks that OpenVMM presents; + /// processors other than 1, 2, 4, or 8; RAM or a RAM capacity that is not a nonzero multiple + /// of [`MEMORY_BLOCK_SIZE`], or a capacity below the RAM; a network policy that allows ingress, + /// or host-loopback access both ways; and a transaction directory that is not valid Unicode, + /// that is relative, which OpenVMM would resolve against the directory in which it runs, or + /// whose path holds a comma or a semicolon, which OpenVMM's block device options would split. + /// + /// It reads, opens, and creates nothing. + pub fn new( + adapter: &SandboxPlan, + binding: &DistroBinding, + scratch_length: u64, + resources: TemplateResources, + transaction: &Path, + generation: StorageGeneration, + ) -> Result { + if adapter.settings().is_some() { + return Err(Error::unsupported( + "a template lowers an adapter's plan, not a plan of the sandbox profile, whose \ + workload settings no template captures", + )); + } + let distro = adapter.distro(); + if bound_image(binding)? != distro.image() { + return Err(Error::unsupported(format!( + "the placeholder {} is not the {} layer with identity {} that its distro binding \ + names", + distro.path().display(), + binding.format(), + binding.identity() + ))); + } + // OpenVMM attaches the placeholder at the path that its arguments name, which they must + // hold whole, as a raw image, and which OpenVMM would resolve against the directory in + // which it runs if it were relative. + let placeholder = distro.path(); + if !placeholder.is_absolute() { + return Err(Error::unsupported(format!( + "the placeholder of a template's capture must be absolute, since OpenVMM would \ + resolve it against the directory in which it runs: {}", + placeholder.display() + ))); + } + let shown = placeholder.display().to_string(); + reject_disk_path(placeholder, &shown)?; + reject_container_path(placeholder, &shown)?; + let capacity = binding.image().length(); + if !is_block_length(capacity) { + return Err(Error::unsupported(format!( + "the placeholder {} of {capacity} bytes cannot be attached: OpenVMM attaches block \ + devices of nonzero multiples of {LOGICAL_BLOCK_SIZE} bytes", + distro.path().display() + ))); + } + if !is_block_length(scratch_length) { + return Err(Error::unsupported(format!( + "a template's scratch of {scratch_length} bytes cannot be attached: OpenVMM \ + attaches block devices of nonzero multiples of {LOGICAL_BLOCK_SIZE} bytes" + ))); + } + check_resources(&resources)?; + if let Some(network) = adapter.network() { + check_policy(network.policy())?; + } + let transaction = transaction_directory(transaction)?; + let scratch = transaction.join(SCRATCH_NAME); + reject_disk_path(&scratch, &scratch.display().to_string())?; + let destination = transaction.join(SNAPSHOT_NAME); + let plan = SandboxPlan::adapter(distro.clone(), SandboxUpper::scratch_at(scratch)) + .with_network(adapter.network().cloned()); + Ok(Self { + plan, + binding: binding.clone(), + scratch_length, + resources, + transaction, + destination, + generation, + }) + } + + /// Returns the capture's devices: an adapter's plan of the placeholder over the dormant + /// scratch device, with the template's NIC, if any. + pub fn plan(&self) -> &SandboxPlan { + &self.plan + } + + /// Returns the binding of the placeholder. + pub fn binding(&self) -> &DistroBinding { + &self.binding + } + + /// Returns the length of the placeholder, in bytes, as its binding records it: the capacity + /// of every clone's image. + pub fn image_capacity(&self) -> u64 { + self.binding.image().length() + } + + /// Returns the dormant scratch device's file, [`SCRATCH_NAME`] in the transaction directory. + pub fn scratch_path(&self) -> &Path { + self.plan + .upper() + .scratch_path() + .expect("a template's upper is its dormant scratch device") + } + + /// Returns the length of the dormant scratch device, in bytes, which its sparse file sets. + pub fn scratch_length(&self) -> u64 { + self.scratch_length + } + + /// Returns the processors and RAM. + pub fn resources(&self) -> TemplateResources { + self.resources + } + + /// Returns the capture's transaction directory. + pub fn transaction(&self) -> &Path { + self.transaction.as_path() + } + + /// Returns the snapshot's destination, [`SNAPSHOT_NAME`] in the transaction directory. + pub fn destination(&self) -> &Path { + self.destination.as_path() + } + + /// Returns the bound-block storage generation of the capture. + pub fn generation(&self) -> StorageGeneration { + self.generation + } + + /// Returns the kernel command line of the capture's VM, `nvx_sandbox=1`, which the caller + /// passes as OpenVMM's `--cmdline`, alone. + /// + /// It takes the place of the command line of [`plan`](Self::plan), which + /// [`SandboxPlan::kernel_command_line`] and a [`PreparedLaunch`] render: that one names the + /// boot tokens of the plan's root, the identity of its image among them, which a clone binds + /// when it is restored, and which OpenVMM's platform allowlist refuses. + /// + /// [`PreparedLaunch`]: crate::host::sandbox::PreparedLaunch + pub fn kernel_command_line(&self) -> &str { + COMMAND_LINE + } + + /// Returns OpenVMM's arguments of the capture: those of its devices, as + /// [`SandboxPlan::openvmm_arguments`] renders them for [`plan`](Self::plan), the microVM + /// machine, the placeholder as the read-only `distro` block device, the dormant scratch device + /// as the `scratch` one, and the NIC, if any; then those of + /// [`capture_arguments`](Self::capture_arguments). + /// + /// The arguments name the block devices by their paths, which OpenVMM opens itself. A capture + /// opens each without following a link at its path, and on Windows without sharing write + /// access, so each path must name the file itself, which no other process holds open for + /// writing. The arguments hold neither the kernel command line, which + /// [`kernel_command_line`](Self::kernel_command_line) gives, nor the caller's own arguments. + /// + /// Fails as [`SandboxPlan::openvmm_arguments`] does. + pub fn openvmm_arguments(&self) -> Result> { + let mut arguments = self.plan.openvmm_arguments()?; + arguments.extend(self.capture_arguments()); + Ok(arguments) + } + + /// Returns OpenVMM's arguments of the capture besides those of its devices: `--processors`, + /// `--memory` and `--memory-capacity` in MiB, and the capture's options, + /// `--snapshot-destination`, `--snapshot-tier platform`, + /// `--snapshot-block-identity generation`, and `--snapshot-generation-id`. + /// + /// A caller that renders the arguments of the devices itself adds these to them. + pub fn capture_arguments(&self) -> Vec { + let TemplateResources { + processors, + memory, + memory_capacity, + } = self.resources; + [ + ("--processors", processors.to_string()), + ("--memory", mebibytes(memory)), + ("--memory-capacity", mebibytes(memory_capacity)), + ( + "--snapshot-destination", + self.destination.display().to_string(), + ), + ("--snapshot-tier", TIER.to_owned()), + ("--snapshot-block-identity", BLOCK_IDENTITY.to_owned()), + ("--snapshot-generation-id", self.generation.to_string()), + ] + .into_iter() + .flat_map(|(option, value)| [option.to_owned(), value]) + .collect() + } +} + +/// Returns whether OpenVMM attaches a sandbox block device of `length` bytes: a nonzero multiple +/// of [`LOGICAL_BLOCK_SIZE`]. +fn is_block_length(length: u64) -> bool { + length != 0 && length.is_multiple_of(LOGICAL_BLOCK_SIZE) +} + +/// Refuses `resources` that no template's VM has: processors other than 1, 2, 4, or 8, and RAM or +/// a RAM capacity that is not a nonzero multiple of [`MEMORY_BLOCK_SIZE`], or a capacity below +/// the RAM. +fn check_resources(resources: &TemplateResources) -> Result<()> { + let TemplateResources { + processors, + memory, + memory_capacity, + } = *resources; + if !PROCESSOR_COUNTS.contains(&processors) { + return Err(Error::unsupported(format!( + "a template's VM has 1, 2, 4, or 8 processors, not {processors}" + ))); + } + if memory == 0 + || !memory.is_multiple_of(MEMORY_BLOCK_SIZE) + || !memory_capacity.is_multiple_of(MEMORY_BLOCK_SIZE) + || memory_capacity < memory + { + return Err(Error::unsupported(format!( + "a template's VM needs nonzero RAM, and a RAM capacity of at least it, each a multiple \ + of {} MiB, not {memory} bytes of RAM and a capacity of {memory_capacity} bytes", + MEMORY_BLOCK_SIZE / MIB + ))); + } + Ok(()) +} + +/// Refuses a network policy that a template's capture cannot take: one that allows ingress, or +/// host-loopback access both ways, which its forwards need, through which OpenVMM publishes the +/// guest's listeners as live host attachments, which no snapshot holds. +fn check_policy(policy: &NetworkPolicy) -> Result<()> { + let allowed = if policy.ingress == Access::Allow { + "ingress" + } else if policy.host_loopback == HostLoopback::Allow { + "host-loopback access both ways" + } else { + return Ok(()); + }; + Err(Error::unsupported(format!( + "a template's network policy cannot allow {allowed}: OpenVMM publishes the guest's \ + listeners as live host attachments, which no snapshot holds" + ))) +} + +/// Returns the transaction directory `path`, spelled with its components alone, as +/// [`Path::components`] reads them, which must be valid Unicode and absolute. +fn transaction_directory(path: &Path) -> Result { + if path.to_str().is_none() { + return Err(Error::unsupported(format!( + "the transaction directory of a template's capture is not valid Unicode: {}", + path.display() + ))); + } + let directory = paths::normalized(path); + if !directory.is_absolute() { + return Err(Error::unsupported(format!( + "the transaction directory of a template's capture must be absolute, since OpenVMM \ + would resolve it against the directory in which it runs: {}", + directory.display() + ))); + } + Ok(directory) +} + +/// Returns `bytes`, a multiple of [`MIB`], as OpenVMM's memory sizes write it: the number of MiB +/// and `M`. +fn mebibytes(bytes: u64) -> String { + format!("{}M", bytes / MIB) +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/host/template/plan/tests.rs b/nvx-rs/src/host/template/plan/tests.rs new file mode 100644 index 000000000..1097a3edb --- /dev/null +++ b/nvx-rs/src/host/template/plan/tests.rs @@ -0,0 +1,942 @@ +//! Tests of the plans of templates' captures: the lowering of adapters' plans, the rendered +//! arguments, the kernel command line against OpenVMM's platform allowlist, and the refusals. + +use std::path::Path; + +use serde_json::json; + +use super::*; +use crate::ErrorKind; +use crate::host::network::{EgressRule, ForwardProtocol, LoopbackForward, NetworkProxy, Protocol}; +use crate::host::sandbox::distro::{BINDING_VERSION, DISTRO_POLICY_REVISION}; +use crate::host::sandbox::{SandboxDistro, SandboxNetwork, SandboxSettings}; +use crate::host::seal::SEAL_VERSION; +use crate::protocol::root::RootImage; +use crate::uuid::Uuid; + +/// OpenVMM's platform allowlist of kernel command-line tokens, compiled from the copies of its +/// source in `tests/vectors/openvmm/`, which an ignored test of `tests/openvmm_vectors.rs` +/// compares with the OpenVMM submodule. +#[allow(unreachable_pub, clippy::missing_docs_in_private_items)] +mod openvmm { + /// OpenVMM's `openvmm_defs` crate, of which the allowlist reads one constant. + mod openvmm_defs { + /// OpenVMM's `openvmm_defs::microvm`. + pub(super) mod microvm { + include!("../../../../tests/vectors/openvmm/microvm_control_tty_command_line.rs"); + } + } + + include!("../../../../tests/vectors/openvmm/platform_command_line_token_is_invariant.rs"); + + /// Returns whether OpenVMM's platform allowlist admits `token`. + pub(super) fn admits(token: &str) -> bool { + platform_command_line_token_is_invariant(token) + } +} + +/// Bytes in a GiB. +const GIB: u64 = 1024 * MIB; + +/// The identity of the placeholder. +const IDENTITY: &str = "11111111-1111-1111-1111-111111111111"; + +/// The placeholder. +const PLACEHOLDER: &str = if cfg!(windows) { + r"C:\platform\placeholder.img" +} else { + "/platform/placeholder.img" +}; + +/// A clone's scratch, which an adapter's plan names as its upper. +const CLONE_SCRATCH: &str = if cfg!(windows) { + r"C:\sandboxes\tenant-a\scratch.ext4" +} else { + "/sandboxes/tenant-a/scratch.ext4" +}; + +/// The capture's transaction directory. +const TRANSACTION: &str = if cfg!(windows) { + r"C:\templates\tx-1" +} else { + "/templates/tx-1" +}; + +/// The dormant scratch device's file in [`TRANSACTION`]. +const SCRATCH: &str = if cfg!(windows) { + r"C:\templates\tx-1\scratch.raw" +} else { + "/templates/tx-1/scratch.raw" +}; + +/// The snapshot's destination in [`TRANSACTION`]. +const DESTINATION: &str = if cfg!(windows) { + r"C:\templates\tx-1\snapshot" +} else { + "/templates/tx-1/snapshot" +}; + +/// The text of [`generation`]. +const GENERATION: &str = "000102030405060708090a0b0c0d0e0f"; + +/// The options of templates' arguments, each of which takes one value. +const OPTIONS: [&str; 17] = [ + "--machine", + "--microvm-sandbox-block", + "--net", + "--network-profile", + "--network-egress", + "--network-ingress", + "--host-loopback", + "--network-egress-allow", + "--network-egress-deny", + "--network-proxy", + "--processors", + "--memory", + "--memory-capacity", + "--snapshot-destination", + "--snapshot-tier", + "--snapshot-block-identity", + "--snapshot-generation-id", +]; + +/// Returns the EROFS image of the placeholder. +fn erofs() -> RootImage { + RootImage::erofs(Some(Uuid::parse(IDENTITY).unwrap())) +} + +/// Returns the GPT image of the placeholder. +fn gpt() -> RootImage { + RootImage::gpt(Some(Uuid::parse(IDENTITY).unwrap())) +} + +/// Returns the binding of a placeholder of `format` with `identity`, of `length` bytes. +fn binding_of(format: &str, identity: &str, length: u64) -> DistroBinding { + serde_json::from_value(json!({ + "version": BINDING_VERSION, + "format": format, + "identity": identity, + "image": { + "version": SEAL_VERSION, + "scheme": "linux", + "device": 2049, + "fileId": "0000000000000000000000000004a2f1", + "kind": "regular", + "length": length, + "modified": {"seconds": 1_792_000_000, "nanoseconds": 5}, + "changed": {"seconds": 1_792_000_000, "nanoseconds": 5}, + }, + "manifest": null, + "policy": {"revision": DISTRO_POLICY_REVISION, "result": "accepted"}, + })) + .unwrap() +} + +/// Returns the binding of the EROFS placeholder of 1 GiB. +fn binding() -> DistroBinding { + binding_of("erofs", IDENTITY, GIB) +} + +/// Returns an adapter's plan of the placeholder as `image` over `upper`, with `network`. +fn adapter_with( + image: RootImage, + upper: SandboxUpper, + network: Option, +) -> SandboxPlan { + adapter_at(PLACEHOLDER, image, upper, network) +} + +/// Returns an adapter's plan of the placeholder at `path` as `image` over `upper`, with +/// `network`. +fn adapter_at( + path: &str, + image: RootImage, + upper: SandboxUpper, + network: Option, +) -> SandboxPlan { + SandboxPlan::adapter(SandboxDistro::new(path, image), upper).with_network(network) +} + +/// Returns an adapter's plan of the EROFS placeholder over a clone's scratch, with `network`. +fn adapter(network: Option) -> SandboxPlan { + adapter_with(erofs(), SandboxUpper::scratch(CLONE_SCRATCH), network) +} + +/// Returns the network of a guest at `10.0.0.2/24` under `policy`. +fn network(policy: NetworkPolicy) -> SandboxNetwork { + SandboxNetwork::new("10.0.0.2/24", policy).unwrap() +} + +/// Returns a policy that allows egress, and host-loopback access from the guest alone. +fn outbound() -> NetworkPolicy { + let mut policy = NetworkPolicy::new(Access::Allow); + policy.host_loopback = HostLoopback::Outbound; + policy +} + +/// Returns a network whose policy allows ingress. +fn listening() -> Option { + let mut policy = NetworkPolicy::new(Access::Deny); + policy.ingress = Access::Allow; + Some(network(policy)) +} + +/// Returns the networks of templates: none; one under a policy that lets nothing through, which +/// attaches no NIC; and NICs that allow egress, egress to some destinations, egress and +/// host-loopback access from the guest, and a proxy alone. +fn networks() -> Vec> { + vec![ + None, + Some(network(NetworkPolicy::new(Access::Deny))), + Some(network(NetworkPolicy::new(Access::Allow))), + Some(network( + NetworkPolicy::new(Access::Deny) + .with_allow(EgressRule::to("192.0.2.0/24").on_port(Protocol::Tcp, 443)) + .with_deny(EgressRule::to("192.0.2.128/25")), + )), + Some(network(outbound())), + Some(network(NetworkPolicy::proxy_only(NetworkProxy::new(3128)))), + ] +} + +/// Returns the resources of `processors`, with 512 MiB of RAM and a capacity of 2 GiB. +fn resources(processors: u32) -> TemplateResources { + TemplateResources { + processors, + memory: 512 * MIB, + memory_capacity: 2 * GIB, + } +} + +/// Returns the storage generation whose bytes are 0 to 15. +fn generation() -> StorageGeneration { + StorageGeneration::from_bytes([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]).unwrap() +} + +/// The inputs of a lowering, which tests change from their defaults: an adapter's plan of the +/// EROFS placeholder over a clone's scratch, without a network, the placeholder's binding, 64 MiB +/// of scratch, the resources of 2 processors, and [`TRANSACTION`]. +#[derive(Clone)] +struct Inputs { + /// The adapter's plan. + adapter: SandboxPlan, + /// The binding of the placeholder. + binding: DistroBinding, + /// The length of the dormant scratch device. + scratch_length: u64, + /// The processors and RAM. + resources: TemplateResources, + /// The transaction directory. + transaction: String, +} + +impl Default for Inputs { + fn default() -> Self { + Self { + adapter: adapter(None), + binding: binding(), + scratch_length: 64 * MIB, + resources: resources(2), + transaction: TRANSACTION.to_owned(), + } + } +} + +impl Inputs { + /// Returns the default inputs with `adapter`. + fn of(adapter: SandboxPlan) -> Self { + Self { + adapter, + ..Self::default() + } + } + + /// Lowers the adapter's plan with the other inputs and [`generation`]. + fn lower(&self) -> Result { + TemplatePlan::new( + &self.adapter, + &self.binding, + self.scratch_length, + self.resources, + Path::new(&self.transaction), + generation(), + ) + } +} + +/// Returns the template of `adapter`, with the other inputs' defaults. +fn template(adapter: SandboxPlan) -> TemplatePlan { + Inputs::of(adapter).lower().unwrap() +} + +/// Returns the templates of every format of placeholder, of adapters' plans over a clone's scratch +/// and over RAM, of every network of [`networks`], and of every processor count. +fn every_template() -> Vec { + let mut templates = Vec::new(); + for (format, image) in [("erofs", erofs()), ("gpt", gpt())] { + for upper in [SandboxUpper::scratch(CLONE_SCRATCH), SandboxUpper::RAM] { + for network in networks() { + for processors in PROCESSOR_COUNTS { + let inputs = Inputs { + adapter: adapter_with(image, upper.clone(), network.clone()), + binding: binding_of(format, IDENTITY, GIB), + resources: resources(processors), + ..Inputs::default() + }; + templates.push(inputs.lower().unwrap()); + } + } + } + } + templates +} + +/// Returns `texts` as owned strings. +fn strings(texts: &[&str]) -> Vec { + texts.iter().map(|text| (*text).to_owned()).collect() +} + +/// Returns the value of the one `option` of `arguments`, whose options each take one value. +fn value<'a>(arguments: &'a [String], option: &str) -> &'a str { + let values: Vec<&str> = arguments + .chunks(2) + .filter(|pair| pair[0] == option) + .map(|pair| pair[1].as_str()) + .collect(); + assert_eq!(values.len(), 1, "{option} in {arguments:?}"); + values[0] +} + +/// Returns the message of the error of `result`, which must be +/// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). +fn refusal(result: Result) -> String { + let error = result.unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + error.to_string() +} + +#[test] +fn templates_render_their_devices_resources_and_capture_options() { + let devices = strings(&[ + "--machine", + "microvm", + "--microvm-sandbox-block", + &format!("distro:file:{PLACEHOLDER},ro"), + "--microvm-sandbox-block", + &format!("scratch:file:{SCRATCH}"), + ]); + let capture = strings(&[ + "--processors", + "2", + "--memory", + "512M", + "--memory-capacity", + "2048M", + "--snapshot-destination", + DESTINATION, + "--snapshot-tier", + "platform", + "--snapshot-block-identity", + "generation", + "--snapshot-generation-id", + GENERATION, + ]); + // No NIC, without a network or with one that lets nothing through. + for network in [None, Some(network(NetworkPolicy::new(Access::Deny)))] { + let template = template(adapter(network)); + assert_eq!(template.capture_arguments(), capture); + assert_eq!( + template.openvmm_arguments().unwrap(), + [devices.clone(), capture.clone()].concat() + ); + } + // One fixed NIC, with the template's policy. + let nic = strings(&["--net", "10.0.0.2/24", "--network-profile", "portable"]); + for (policy, enforced) in [ + ( + NetworkPolicy::new(Access::Allow), + strings(&[ + "--network-egress", + "allow", + "--network-ingress", + "deny", + "--host-loopback", + "deny", + ]), + ), + ( + outbound(), + strings(&["--network-egress", "allow", "--network-ingress", "deny"]), + ), + ( + NetworkPolicy::proxy_only(NetworkProxy::new(3128)), + strings(&[ + "--network-egress", + "deny", + "--network-ingress", + "deny", + "--host-loopback", + "deny", + "--network-proxy", + "10.0.0.1:3128", + ]), + ), + ] { + let template = template(adapter(Some(network(policy)))); + assert_eq!(template.capture_arguments(), capture); + assert_eq!( + template.openvmm_arguments().unwrap(), + [devices.clone(), nic.clone(), enforced, capture.clone()].concat() + ); + } + // RAM and its capacity in MiB. + let inputs = Inputs { + resources: TemplateResources { + processors: 8, + memory: MEMORY_BLOCK_SIZE, + memory_capacity: MEMORY_BLOCK_SIZE, + }, + ..Inputs::default() + }; + assert_eq!( + inputs.lower().unwrap().capture_arguments()[..6], + strings(&[ + "--processors", + "8", + "--memory", + "128M", + "--memory-capacity", + "128M", + ]) + ); +} + +#[test] +fn templates_keep_the_placeholder_and_the_files_of_their_capture_in_its_transaction() { + let network = Some(network(NetworkPolicy::new(Access::Allow))); + let template = template(adapter(network.clone())); + let plan = template.plan(); + assert_eq!(plan.distro().path(), Path::new(PLACEHOLDER)); + assert_eq!(plan.distro().image(), erofs()); + assert_eq!(plan.upper().scratch_path(), Some(Path::new(SCRATCH))); + assert_eq!(plan.settings(), None); + assert_eq!(plan.export(), None); + assert_eq!(plan.network(), network.as_ref()); + assert_eq!(template.binding(), &binding()); + assert_eq!(template.image_capacity(), GIB); + assert_eq!(template.scratch_path(), Path::new(SCRATCH)); + assert_eq!(template.scratch_length(), 64 * MIB); + assert_eq!(template.resources(), resources(2)); + assert_eq!(template.transaction(), Path::new(TRANSACTION)); + assert_eq!(template.destination(), Path::new(DESTINATION)); + assert_eq!(template.generation(), generation()); + assert_eq!(template.kernel_command_line(), "nvx_sandbox=1"); + assert_eq!( + (SCRATCH_NAME, SNAPSHOT_NAME, MEMORY_BLOCK_SIZE), + ("scratch.raw", "snapshot", 128 * MIB) + ); + // The capacity of every clone's image is the placeholder's length, as its binding records it. + let inputs = Inputs { + binding: binding_of("erofs", IDENTITY, 8 * GIB + 512), + scratch_length: 512, + ..Inputs::default() + }; + let template = inputs.lower().unwrap(); + assert_eq!( + (template.image_capacity(), template.scratch_length()), + (8 * GIB + 512, 512) + ); +} + +#[test] +fn the_upper_of_an_adapters_plan_is_a_clones_and_no_part_of_its_template() { + let over_scratch = template(adapter(None)); + let over_ram = template(adapter_with(erofs(), SandboxUpper::RAM, None)); + assert_eq!(over_ram, over_scratch); + assert_eq!( + over_ram.openvmm_arguments().unwrap(), + over_scratch.openvmm_arguments().unwrap() + ); + assert_eq!(over_ram.scratch_path(), Path::new(SCRATCH)); +} + +#[test] +fn transaction_directories_are_spelled_with_their_components() { + let given = if cfg!(windows) { + r"C:/templates//tx-1/." + } else { + "/templates//tx-1/./" + }; + let inputs = Inputs { + transaction: given.to_owned(), + ..Inputs::default() + }; + let template = inputs.lower().unwrap(); + assert_eq!(template.transaction().to_str(), Some(TRANSACTION)); + assert_eq!(template.scratch_path().to_str(), Some(SCRATCH)); + assert_eq!(template.destination().to_str(), Some(DESTINATION)); + assert_eq!(template, Inputs::default().lower().unwrap()); +} + +#[test] +fn openvmms_platform_allowlist_admits_its_tokens_and_refuses_tenant_data() { + for token in [ + "earlycon=xe9", + "console=hvc0", + "console=hvc1", + "reboot=t", + "panic=-1", + "nvx_sandbox=1", + "nvx_config=0xd0010000,65536", + "nvx_snapshot_tier=platform", + "nr_cpus=8", + "nvx_control_tty=hvc2", + "virtio_mmio.device=0x1000@0xd0001000:6", + "virtnet_ip=10.0.0.2", + "virtnet_mask=255.255.255.0", + "virtnet_gw=10.0.0.1", + "virtnet_dns=10.0.0.1", + "virtnet_ip6=fd00::2/64", + "virtnet_gw6=fd00::1", + ] { + assert!(openvmm::admits(token), "{token}"); + } + // The tokens of a root, a workload, a share, a listener, and anything else that a tenant's + // sandbox or another tier sets. + let [layer, upper] = adapter(None).layout().tokens(); + let [_, ram] = adapter_with(gpt(), SandboxUpper::RAM, None) + .layout() + .tokens(); + for token in [ + layer.as_str(), + upper.as_str(), + ram.as_str(), + "nvx_hostname=tenant-a", + "hostname=tenant-a", + "nvx_entrypoint=/bin/sh", + "nvx_arg=-c", + "nvx_memory_max=1073741824", + "nvx_maps=1", + "nvx_workload_account=create", + "nvx_workload_uid=1000", + "nvx_workload_gid=1000", + "nvx_lifecycle=managed", + "virtfs_dir=0", + "virtnet_ingress=1", + "nvx_proxy=http://10.0.0.1:3128", + "nvx_config=tenant-data", + "nvx_sandbox=0", + "nvx_snapshot_tier=workload-start", + "console=ttyS0", + "quiet", + "", + ] { + assert!(!openvmm::admits(token), "{token}"); + } +} + +#[test] +fn the_command_line_holds_only_tokens_of_openvmms_platform_allowlist() { + for template in every_template() { + let arguments = template.openvmm_arguments().unwrap(); + let command_line = template.kernel_command_line(); + let mut tokens: Vec = command_line.split(' ').map(str::to_owned).collect(); + // The tokens that OpenVMM writes for the template's arguments. + tokens.push(format!("nr_cpus={}", value(&arguments, "--processors"))); + tokens.push(format!( + "nvx_snapshot_tier={}", + value(&arguments, "--snapshot-tier") + )); + if arguments.iter().any(|argument| argument == "--net") { + let network = template.plan().network().unwrap(); + let (address, _) = network.guest_network().split_once('/').unwrap(); + tokens.push(format!("virtnet_ip={address}")); + tokens.push(format!("virtnet_gw={}", network.gateway())); + } + for token in &tokens { + assert!(openvmm::admits(token), "{token} of {template:?}"); + } + // The command line of the template's devices names the root of its plan, which a clone + // binds, and which the allowlist refuses: the template's replaces it. + let root = template.plan().kernel_command_line("").unwrap(); + for token in root.split(' ') { + assert!(!openvmm::admits(token), "{token} of {root}"); + assert!(!command_line.contains(token), "{token} in {command_line}"); + } + } +} + +#[test] +fn templates_hold_no_tenant_data() { + for template in every_template() { + let arguments = template.openvmm_arguments().unwrap(); + let command_line = template.kernel_command_line(); + // Neither the identity of the image, nor the clone's upper, nor the proxy. + for text in [IDENTITY, "tenant", "3128", "/sandboxes", r"\sandboxes"] { + assert!(!command_line.contains(text), "{text} in {command_line}"); + } + for text in [IDENTITY, "tenant", "/sandboxes", r"\sandboxes"] { + assert!( + arguments.iter().all(|argument| !argument.contains(text)), + "{text} in {arguments:?}" + ); + } + // No kernel command line, workload identity, lifecycle, share, published listener, or + // other option besides those of the devices, the resources, and the capture, each with one + // value. + for pair in arguments.chunks(2) { + let [option, value] = pair else { + panic!("{arguments:?} ends in an option without a value"); + }; + let option = option.as_str(); + assert!(OPTIONS.contains(&option), "{option} in {arguments:?}"); + assert!(!value.starts_with("--"), "{option} {value}"); + if ["--network-ingress", "--host-loopback"].contains(&option) { + assert_eq!(value, "deny", "{option} in {arguments:?}"); + } + } + let devices = template.plan().openvmm_arguments().unwrap(); + assert_eq!([devices, template.capture_arguments()].concat(), arguments); + } +} + +#[test] +fn templates_refuse_what_no_capture_takes() { + // A plan of the sandbox profile. + let profile = SandboxPlan::new( + SandboxDistro::new(PLACEHOLDER, erofs()), + SandboxUpper::scratch(CLONE_SCRATCH), + SandboxSettings::default(), + ) + .unwrap(); + assert_eq!( + refusal(Inputs::of(profile).lower()), + "a template lowers an adapter's plan, not a plan of the sandbox profile, whose workload \ + settings no template captures" + ); + + // A placeholder that is not the image that its binding names. + let other = "22222222-2222-2222-2222-222222222222"; + for (image, binding) in [ + (gpt(), binding()), + (RootImage::erofs(None), binding()), + (erofs(), binding_of("erofs", other, GIB)), + (erofs(), binding_of("gpt", IDENTITY, GIB)), + ] { + let expected = format!( + "the placeholder {PLACEHOLDER} is not the {} layer with identity {} that its distro \ + binding names", + binding.format(), + binding.identity() + ); + let inputs = Inputs { + adapter: adapter_with(image, SandboxUpper::RAM, None), + binding, + ..Inputs::default() + }; + assert_eq!(refusal(inputs.lower()), expected, "{image:?}"); + } + let inputs = Inputs { + binding: binding_of("erofs", "11111111-1111-1111-1111-11111111111A", GIB), + ..Inputs::default() + }; + assert_eq!( + refusal(inputs.lower()), + "the distro binding names the identity \"11111111-1111-1111-1111-11111111111A\", which \ + is not a UUID in its canonical text" + ); + + // Placeholders that OpenVMM would resolve against its own directory. + let mut relative = vec!["platform/placeholder.img"]; + if cfg!(windows) { + // Rooted on the current drive, and relative to a drive's current directory. + relative.extend([r"\platform\placeholder.img", r"C:platform\placeholder.img"]); + } + for path in relative { + let inputs = Inputs::of(adapter_at(path, erofs(), SandboxUpper::RAM, None)); + assert_eq!( + refusal(inputs.lower()), + format!( + "the placeholder of a template's capture must be absolute, since OpenVMM would \ + resolve it against the directory in which it runs: {}", + paths::normalized(Path::new(path)).display() + ) + ); + } + // Placeholders that OpenVMM's block device options would split, and that it would not attach + // as raw images. + for (name, problem) in [ + ( + "placeholder,1.img", + "containing commas or semicolons are unsupported", + ), + ( + "placeholder;1.img", + "containing commas or semicolons are unsupported", + ), + ( + "placeholder.vhd", + "ending in .vhd are unsupported, since OpenVMM would not attach them as raw images", + ), + ( + "placeholder.vhdx", + "ending in .vhdx are unsupported, since OpenVMM would not attach them as raw images", + ), + ( + "placeholder.vmgs", + "ending in .vmgs are unsupported, since OpenVMM would not attach them as raw images", + ), + ( + "placeholder.iso", + "ending in .iso are unsupported, since OpenVMM would not attach them as raw images", + ), + ] { + let path = Path::new(PLACEHOLDER).with_file_name(name); + let path = path.to_str().unwrap(); + let inputs = Inputs::of(adapter_at(path, erofs(), SandboxUpper::RAM, None)); + assert_eq!( + refusal(inputs.lower()), + format!("disk paths {problem}: {path}") + ); + } + + // Block devices that OpenVMM does not attach. + for length in [0, 511, 1000, GIB + 1] { + let placeholder = Inputs { + binding: binding_of("erofs", IDENTITY, length), + ..Inputs::default() + }; + assert_eq!( + refusal(placeholder.lower()), + format!( + "the placeholder {PLACEHOLDER} of {length} bytes cannot be attached: OpenVMM \ + attaches block devices of nonzero multiples of 512 bytes" + ) + ); + let scratch = Inputs { + scratch_length: length, + ..Inputs::default() + }; + assert_eq!( + refusal(scratch.lower()), + format!( + "a template's scratch of {length} bytes cannot be attached: OpenVMM attaches \ + block devices of nonzero multiples of 512 bytes" + ) + ); + } + + // Processor counts other than 1, 2, 4, and 8. + for processors in [0, 3, 6, 16, u32::MAX] { + let inputs = Inputs { + resources: resources(processors), + ..Inputs::default() + }; + assert_eq!( + refusal(inputs.lower()), + format!("a template's VM has 1, 2, 4, or 8 processors, not {processors}") + ); + } + // RAM and capacities that are not whole blocks, and capacities below the RAM. + for (memory, memory_capacity) in [ + (0, 0), + (0, 2 * GIB), + (100 * MIB, 2 * GIB), + (512 * MIB, 2 * GIB + MIB), + (512 * MIB, 384 * MIB), + (512 * MIB, 0), + ] { + let inputs = Inputs { + resources: TemplateResources { + processors: 2, + memory, + memory_capacity, + }, + ..Inputs::default() + }; + assert_eq!( + refusal(inputs.lower()), + format!( + "a template's VM needs nonzero RAM, and a RAM capacity of at least it, each a \ + multiple of 128 MiB, not {memory} bytes of RAM and a capacity of \ + {memory_capacity} bytes" + ) + ); + } + + // Policies through which OpenVMM publishes the guest's listeners. + let mut both_ways = NetworkPolicy::new(Access::Allow); + both_ways.host_loopback = HostLoopback::Allow; + let mut forwarded = both_ways.clone(); + forwarded.host_loopback_forwards.push(LoopbackForward { + protocol: ForwardProtocol::Tcp, + host_port: 18080, + guest_port: 8080, + }); + for (network, allowed) in [ + (listening(), "ingress"), + (Some(network(both_ways)), "host-loopback access both ways"), + (Some(network(forwarded)), "host-loopback access both ways"), + ] { + assert_eq!( + refusal(Inputs::of(adapter(network)).lower()), + format!( + "a template's network policy cannot allow {allowed}: OpenVMM publishes the \ + guest's listeners as live host attachments, which no snapshot holds" + ) + ); + } + + // Transaction directories that OpenVMM would resolve against its own directory. + let mut relative = vec!["templates/tx-1", "."]; + if cfg!(windows) { + // Rooted on the current drive, and relative to a drive's current directory. + relative.extend([r"\templates\tx-1", r"C:templates\tx-1"]); + } + for transaction in relative { + let inputs = Inputs { + transaction: transaction.to_owned(), + ..Inputs::default() + }; + assert_eq!( + refusal(inputs.lower()), + format!( + "the transaction directory of a template's capture must be absolute, since \ + OpenVMM would resolve it against the directory in which it runs: {}", + paths::normalized(Path::new(transaction)).display() + ) + ); + } + // Transaction directories whose scratch device OpenVMM's options would split. + for name in ["a,b", "a;b"] { + let inputs = Inputs { + transaction: format!("{TRANSACTION}-{name}"), + ..Inputs::default() + }; + assert_eq!( + refusal(inputs.lower()), + format!( + "disk paths containing commas or semicolons are unsupported: {}", + Path::new(&inputs.transaction).join(SCRATCH_NAME).display() + ) + ); + } +} + +/// Lowers the default inputs with the transaction directory `transaction`. +fn lower_in(transaction: &Path) -> Result { + let inputs = Inputs::default(); + TemplatePlan::new( + &inputs.adapter, + &inputs.binding, + inputs.scratch_length, + inputs.resources, + transaction, + generation(), + ) +} + +#[cfg(unix)] +#[test] +fn transaction_directories_must_be_valid_unicode() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let transaction = Path::new(OsStr::from_bytes(b"/templates/tx-\xff")); + assert_eq!( + refusal(lower_in(transaction)), + "the transaction directory of a template's capture is not valid Unicode: \ + /templates/tx-\u{fffd}" + ); +} + +#[cfg(windows)] +#[test] +fn transaction_directories_must_be_valid_unicode() { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + use std::path::PathBuf; + + let mut units: Vec = r"C:\templates\tx-".encode_utf16().collect(); + units.push(0xd800); + let transaction = PathBuf::from(OsString::from_wide(&units)); + assert_eq!( + refusal(lower_in(&transaction)), + "the transaction directory of a template's capture is not valid Unicode: \ + C:\\templates\\tx-\u{fffd}" + ); +} + +/// A change to the inputs of a lowering. +type Fix = fn(&mut Inputs); + +#[test] +fn templates_check_their_inputs_in_order() { + // With every input refused, each fix in turn reveals the next refusal. + let mut inputs = Inputs { + adapter: SandboxPlan::new( + SandboxDistro::new(PLACEHOLDER, gpt()), + SandboxUpper::RAM, + SandboxSettings::default(), + ) + .unwrap() + .with_network(listening()), + binding: binding_of("erofs", IDENTITY, 1000), + scratch_length: 0, + resources: TemplateResources { + processors: 3, + memory: 0, + memory_capacity: 0, + }, + transaction: "templates".to_owned(), + }; + let steps: [(&str, Fix); 11] = [ + ("a template lowers an adapter's plan", |inputs| { + inputs.adapter = adapter_at("placeholder.vhd", gpt(), SandboxUpper::RAM, listening()); + }), + ("is not the erofs layer", |inputs| { + inputs.adapter = adapter_at("placeholder.vhd", erofs(), SandboxUpper::RAM, listening()); + }), + ( + "the placeholder of a template's capture must be absolute", + |inputs| { + inputs.adapter = adapter_at( + &PLACEHOLDER.replace(".img", ".vhd"), + erofs(), + SandboxUpper::RAM, + listening(), + ); + }, + ), + ("disk paths ending in .vhd", |inputs| { + inputs.adapter = adapter(listening()); + }), + ("of 1000 bytes cannot be attached", |inputs| { + inputs.binding = binding(); + }), + ("a template's scratch of 0 bytes", |inputs| { + inputs.scratch_length = 512; + }), + ("processors, not 3", |inputs| { + inputs.resources.processors = 2 + }), + ("needs nonzero RAM", |inputs| { + inputs.resources = resources(2) + }), + ("cannot allow ingress", |inputs| { + inputs.adapter = adapter(None); + }), + ("must be absolute", |inputs| { + inputs.transaction = format!("{TRANSACTION},1"); + }), + ("commas or semicolons", |inputs| { + inputs.transaction = TRANSACTION.to_owned(); + }), + ]; + for (refused, fix) in steps { + let message = refusal(inputs.lower()); + assert!(message.contains(refused), "{refused}: {message}"); + fix(&mut inputs); + } + assert_eq!(inputs.lower().unwrap().scratch_length(), 512); +} diff --git a/nvx-rs/tests/openvmm_vectors.rs b/nvx-rs/tests/openvmm_vectors.rs index 0edb51059..eee533c5f 100644 --- a/nvx-rs/tests/openvmm_vectors.rs +++ b/nvx-rs/tests/openvmm_vectors.rs @@ -1,10 +1,11 @@ //! Compares what `nvx-rs` copies from OpenVMM with the OpenVMM submodule: the control-session -//! protocol vectors in `tests/vectors/openvmm/`, and the versions of OpenVMM's contracts in -//! `nvx::contract`. +//! protocol vectors and the excerpts of OpenVMM's sources in `tests/vectors/openvmm/`, and the +//! versions of OpenVMM's contracts in `nvx::contract`. //! -//! The protocol's tests read the copies, because the `nvx-rs` job cannot check out the submodule, -//! whose deploy key fork pull requests lack. This test is ignored by default: the NVX CLI jobs, -//! which check out the submodule, run it, and so does an OpenVMM pin promotion. +//! The protocol's tests read the copies, and the unit tests of `nvx::host::template` compile the +//! excerpts, because the `nvx-rs` job cannot check out the submodule, whose deploy key fork pull +//! requests lack. This test is ignored by default: the NVX CLI jobs, which check out the +//! submodule, run it, and so does an OpenVMM pin promotion. use std::fs; use std::path::Path; @@ -18,6 +19,19 @@ const FILES: [&str; 2] = [ "control_session_protocol_vectors.txt", ]; +/// The excerpts of OpenVMM's sources that `tests/vectors/openvmm/` copies: for each, its file +/// there, and the file of the OpenVMM submodule whose lines it copies, unchanged. +const EXCERPTS: [(&str, &str); 2] = [ + ( + "microvm_control_tty_command_line.rs", + "openvmm/openvmm_defs/src/microvm.rs", + ), + ( + "platform_command_line_token_is_invariant.rs", + "openvmm/openvmm_helpers/src/snapshot/microvm.rs", + ), +]; + /// The versions of `nvx::contract` that are OpenVMM's: for each, the file of the OpenVMM /// submodule that defines OpenVMM's, the start of the line that defines it, up to its value, and /// the version of `nvx::contract`. @@ -55,7 +69,12 @@ fn the_copies_match_the_submodules_files() { .map(|entry| entry.unwrap().file_name().into_string().unwrap()) .collect(); copied.sort(); - assert_eq!(copied, FILES, "tests/vectors/openvmm/ holds other files"); + let mut expected: Vec<&str> = FILES + .into_iter() + .chain(EXCERPTS.map(|(excerpt, _)| excerpt)) + .collect(); + expected.sort_unstable(); + assert_eq!(copied, expected, "tests/vectors/openvmm/ holds other files"); for file in FILES { let original = fs::read(originals.join(file)).unwrap_or_else(|error| { panic!( @@ -72,6 +91,30 @@ fn the_copies_match_the_submodules_files() { } } +#[test] +#[ignore = "needs the OpenVMM submodule, which the NVX CLI jobs check out"] +fn the_excerpts_are_lines_of_the_submodules_files() { + let crate_root = Path::new(env!("CARGO_MANIFEST_DIR")); + for (excerpt, file) in EXCERPTS { + let source = + fs::read_to_string(crate_root.join("../openvmm").join(file)).unwrap_or_else(|error| { + panic!( + "cannot read {file} of the OpenVMM submodule, which `scripts/nvx.py init` \ + checks out: {error}" + ) + }); + let copy = + fs::read_to_string(crate_root.join("tests/vectors/openvmm").join(excerpt)).unwrap(); + // Whole lines: the excerpt starts at the start of a line, and ends with its newline. + assert!( + copy.ends_with('\n') && format!("\n{source}").contains(&format!("\n{copy}")), + "tests/vectors/openvmm/{excerpt} is not a run of whole lines of {file} of the OpenVMM \ + submodule: copy the submodule's lines unchanged, record its commit in \ + tests/vectors/README.md, and update the tests that they fail" + ); + } +} + #[test] #[ignore = "needs the OpenVMM submodule, which the NVX CLI jobs check out"] fn the_contract_versions_are_the_submodules() { diff --git a/nvx-rs/tests/vectors/README.md b/nvx-rs/tests/vectors/README.md index 1b8cbb841..b679d522e 100644 --- a/nvx-rs/tests/vectors/README.md +++ b/nvx-rs/tests/vectors/README.md @@ -13,7 +13,7 @@ yet breaks those peers. Only bytes that no codec produced catch such a change. | [`nvxl-v1.txt`](nvxl-v1.txt) | Version 1 of the `NVXL` listener announcement | | [`activation-v1.txt`](activation-v1.txt) | Version 1 of the activation record of a layered root, and what Ready reports about an activation | | [`template-key-v1.txt`](template-key-v1.txt) | Version 1 of the canonical encodings of a template's compatibility key and of a capture class, with their identities | -| [`openvmm/`](openvmm) | OpenVMM's vectors and cases of the `NVXS` session, unchanged | +| [`openvmm/`](openvmm) | OpenVMM's vectors and cases of the `NVXS` session, and the excerpts of its sources that define the kernel command-line tokens of its platform tier, unchanged | [4.4 Host-Guest Control Interface](../../../doc/design/4.4-host-guest-control-interface.md) and @@ -69,27 +69,36 @@ the key that the vector's bytes decode to. `openvmm/` holds `control_session_protocol_vectors.txt` and `control_session_protocol_cases.txt` of OpenVMM's `vm/devices/virtio/virtio_console/test_data/`, unchanged, from OpenVMM commit -[`79a501b63fcd2f150e94928d0ceba79cd2fe96c4`](https://github.com/microsoft/openvmm/tree/79a501b63fcd2f150e94928d0ceba79cd2fe96c4/vm/devices/virtio/virtio_console/test_data). +[`e68871cc6f14fceb6d38a899504ae8105f89e310`](https://github.com/microsoft/openvmm/tree/e68871cc6f14fceb6d38a899504ae8105f89e310/vm/devices/virtio/virtio_console/test_data). They keep OpenVMM's own format, which its `control_session_protocol` tests read: one record of each type, as `NAME|BYTES`, and 37 cases of the boundaries and refusals of the records' payloads and headers. The tests of `nvx::protocol::nvxs` check that each vector decodes and encodes to its bytes, and that each case is accepted or refused as it says. -The tests cannot call OpenVMM's broker: OpenVMM's workspace needs a later Rust -release than `nvx`'s minimum, and the `nvx-rs` job cannot check out the OpenVMM -submodule, whose deploy key fork pull requests lack. They read the copies -instead, and the ignored test `tests/openvmm_vectors.rs` compares the copies -with the submodule's files. It also checks that the versions of OpenVMM's -snapshot format, saved-state schema, time ABI, and restore packet in -`nvx::contract` are the submodule's. The NVX CLI jobs, which check out the -submodule, run it, and so does an OpenVMM pin promotion, from the repository's -root: +`openvmm/` also holds two excerpts of OpenVMM's sources at that commit, whole +lines, unchanged: `platform_command_line_token_is_invariant.rs`, the function +of `openvmm/openvmm_helpers/src/snapshot/microvm.rs` that admits the kernel +command-line tokens of a capture at OpenVMM's platform tier, and +`microvm_control_tty_command_line.rs`, the constant of +`openvmm/openvmm_defs/src/microvm.rs` that names the control tty's token, which +the function reads. The tests of `nvx::host::template` compile them, and check +each token of a template's kernel command line with OpenVMM's own function. + +The tests cannot call OpenVMM's broker or its allowlist: OpenVMM's workspace +needs a later Rust release than `nvx`'s minimum, and the `nvx-rs` job cannot +check out the OpenVMM submodule, whose deploy key fork pull requests lack. They +read the copies instead, and the ignored test `tests/openvmm_vectors.rs` +compares the copies with the submodule's files, and finds each excerpt among +the lines of its file. It also checks that the versions of OpenVMM's snapshot +format, saved-state schema, time ABI, and restore packet in `nvx::contract` are +the submodule's. The NVX CLI jobs, which check out the submodule, run it, and +so does an OpenVMM pin promotion, from the repository's root: ```bash cargo test --locked --manifest-path nvx-rs/Cargo.toml --no-default-features \ --test openvmm_vectors -- --ignored ``` -When a pin changes OpenVMM's files, copy them here unchanged, record the -commit above, and update the tests that they fail. +When a pin changes OpenVMM's files, or the lines of an excerpt, copy them here +unchanged, record the commit above, and update the tests that they fail. diff --git a/nvx-rs/tests/vectors/openvmm/microvm_control_tty_command_line.rs b/nvx-rs/tests/vectors/openvmm/microvm_control_tty_command_line.rs new file mode 100644 index 000000000..ec77359b8 --- /dev/null +++ b/nvx-rs/tests/vectors/openvmm/microvm_control_tty_command_line.rs @@ -0,0 +1,2 @@ +/// Host-owned kernel command-line token identifying the control tty. +pub const MICROVM_CONTROL_TTY_COMMAND_LINE: &str = "nvx_control_tty=hvc2"; diff --git a/nvx-rs/tests/vectors/openvmm/platform_command_line_token_is_invariant.rs b/nvx-rs/tests/vectors/openvmm/platform_command_line_token_is_invariant.rs new file mode 100644 index 000000000..4486d4276 --- /dev/null +++ b/nvx-rs/tests/vectors/openvmm/platform_command_line_token_is_invariant.rs @@ -0,0 +1,26 @@ +fn platform_command_line_token_is_invariant(token: &str) -> bool { + matches!( + token, + "earlycon=xe9" + | "console=hvc0" + | "console=hvc1" + | "reboot=t" + | "panic=-1" + | "nvx_sandbox=1" + | "nvx_config=0xd0010000,65536" + | "nvx_snapshot_tier=platform" + ) || token.starts_with("nr_cpus=") + || token.starts_with("maxcpus=") + || token == openvmm_defs::microvm::MICROVM_CONTROL_TTY_COMMAND_LINE + || [ + "virtio_mmio.device=", + "virtnet_ip=", + "virtnet_mask=", + "virtnet_gw=", + "virtnet_dns=", + "virtnet_ip6=", + "virtnet_gw6=", + ] + .iter() + .any(|prefix| token.starts_with(prefix)) +}