From 75b863e0ebb2ea2180d4aea046d2f1ae3df666b2 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Wed, 4 Nov 2026 00:46:55 -0800 Subject: [PATCH 1/3] nvx-rs: carry the root's settings in its boot tokens, and cap a RAM upper The boot tokens of a layered root gain four optional settings, after the image's and the upper's: nvx_overlay_mode, trusted-lowers or untrusted-lowers; nvx_scratch_journal, allowed or forbidden; nvx_first_partition_bound, a GPT disk's bound in bytes; and nvx_ram_upper_percent, a RAM upper's cap from 1 to 90 percent of the VM's memory. RootLayout names each as an Option that renders no token when it is None, so that the guest keeps its default. RootLayout::parse reads them strictly: each once, with a value in its decimal or named form, only beside an image, and the journal policy only with a scratch upper, the cap with a RAM upper, and the bound with a GPT image. The shared fixture records the new cases, which nvx-rs and the shell init both replay. guest::root's Upper::Tmpfs takes the cap, mounts the tmpfs with size=N% when it has one, and refuses a cap outside 1 to 90 percent before anything changes, since the kernel reads 0 as no cap. NVX's shell init reads the four tokens with the parser's rules and messages, mounts a capped RAM upper with size=N%, and refuses, before it mounts anything, untrusted lowers and a forbidden journal, which it cannot assemble, as it refuses a GPT image. The sandbox plan names none of the settings, so its command lines stay as they were, and an adapter's plan refuses a setting that its command line names, since it reads the root's tokens back. A clone's activation assembles its tmpfs without a cap, since its record names none, and the runtime adapter's worker hands the cap of its boot tokens to its root. BOOT_CONTRACT_VERSION stays 5, since nothing deployed renders the new tokens. The public API snapshots list the new items. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e354d65-494d-4fa9-ae9b-4f2e86c3c0f8 --- .../4.1-guest-filesystem-and-image-model.md | 30 ++- guest/common/nvx-init-agent | 81 +++++- nvx-rs/README.md | 31 ++- nvx-rs/public-api/linux.txt | 11 + nvx-rs/public-api/windows.txt | 8 + nvx-rs/src/guest/clone/activation/apply.rs | 5 +- .../src/guest/clone/activation/apply/tests.rs | 12 +- nvx-rs/src/guest/root.rs | 53 +++- nvx-rs/src/guest/root/tests.rs | 122 ++++++--- nvx-rs/src/host/sandbox/plan.rs | 7 +- nvx-rs/src/host/sandbox/plan/tests.rs | 42 ++- nvx-rs/src/host/sandbox/tests.rs | 4 + nvx-rs/src/host/template/plan/tests.rs | 12 +- nvx-rs/src/protocol/root.rs | 248 ++++++++++++++--- nvx-rs/src/protocol/root/tests.rs | 255 ++++++++++++++++-- nvx-rs/tests/fixtures/root-tokens.json | 43 ++- scripts/test_nvx_tools.py | 132 ++++++++- tests/runtime-adapter/README.md | 8 +- tests/runtime-adapter/src/worker.rs | 14 +- .../src/worker/activation/tests.rs | 4 +- tests/runtime-adapter/tests/guest_session.rs | 10 +- tests/runtime-adapter/tests/layered_launch.rs | 4 + 22 files changed, 972 insertions(+), 164 deletions(-) diff --git a/doc/design/4.1-guest-filesystem-and-image-model.md b/doc/design/4.1-guest-filesystem-and-image-model.md index 4d64c77b5..3a7500bbf 100644 --- a/doc/design/4.1-guest-filesystem-and-image-model.md +++ b/doc/design/4.1-guest-filesystem-and-image-model.md @@ -45,8 +45,9 @@ stored and cached image bytes, because the guest kernel decompresses blocks as it reads them. No FUSE daemon or in-guest fetch service is involved in the root filesystem. -The launcher describes the root to the guest in two boot tokens, whose grammar -one contract module defines, with the host's renderer and a parser for guests: +The launcher describes the root to the guest in two boot tokens, and in up to +four more for its settings, whose grammar one contract module defines, with the +host's renderer and a parser for guests: - `nvx_layer=distro,0xd0003000,FORMAT[:IDENTITY]` names the image on the `distro` block device: its format, `erofs` for an EROFS filesystem or `gpt` @@ -56,13 +57,23 @@ one contract module defines, with the host's renderer and a parser for guests: - `nvx_scratch=0xd0006000,ext4` keeps the overlay's upper on the whole-device ext4 `scratch` disk, and `nvx_overlay_upper=ramfs` keeps it in guest memory, on a tmpfs, with no `scratch` block device. +- `nvx_overlay_mode=trusted-lowers` or `untrusted-lowers` names the overlay's + option set, `nvx_scratch_journal=allowed` or `forbidden` whether the + scratch's ext4 filesystem may have a journal, `nvx_first_partition_bound` + the most bytes of a GPT disk's first partition, and `nvx_ram_upper_percent` + the tmpfs upper's cap, from 1 to 90 percent of the guest's memory. A number + is decimal, without a sign or leading zeros. The guest takes its default for + a setting without a token: trusted lowers, a journal allowed, and the + kernel's default size of a tmpfs. Guests refuse a token without a value, a repeated token, both uppers, an image -without an upper or an upper without an image, a RAM upper beside a `scratch` -block device, and every other form, including the earlier `nvx_layer` form -without a format, rather than probe the devices. The guest agents described -here assemble only an EROFS image, over either upper: they refuse a GPT image -with a message that says so. +without an upper or an upper or a setting without an image, a setting of +another upper or of another format, a RAM upper beside a `scratch` block +device, and every other form, including the earlier `nvx_layer` form without a +format, rather than probe the devices. The guest agents described here +assemble only an EROFS image, over either upper, for trusted lowers, and don't +check a scratch for a journal: they refuse a GPT image, untrusted lowers, and +a forbidden journal with a message that says so. The guest agents assemble the root in this order: @@ -77,8 +88,9 @@ The guest agents assemble the root in this order: the token names one. 4. Mount the upper's file system, and create the overlay's upper and work directories on it: resolve and flush scratch and mount it as ext4 with - `rw,nosuid,nodev`, or mount a tmpfs with `mode=0755,nosuid,nodev` at the - kernel's default size, half of the guest's RAM. + `rw,nosuid,nodev`, or mount a tmpfs with `mode=0755,nosuid,nodev`, and + `size=N%` when `nvx_ram_upper_percent=N` caps it, or else at the kernel's + default size, half of the guest's RAM. 5. Mount the overlay with the `distro` layer as its lower directory, metadata-only copy-up, and extended inode numbers. 6. Verify the workload identity in the assembled root, mount each live share diff --git a/guest/common/nvx-init-agent b/guest/common/nvx-init-agent index dcad77a21..15d6ea49d 100644 --- a/guest/common/nvx-init-agent +++ b/guest/common/nvx-init-agent @@ -212,24 +212,36 @@ read_layer_token() { # Reads the boot tokens of the layered root, with the rules of the host's # renderer, nvx::protocol::root: nvx_layer names the image of the distro block -# device, and nvx_scratch=0xd0006000,ext4 or nvx_overlay_upper=ramfs the upper, -# each once. This init assembles only an EROFS image, so it also refuses a GPT -# image. Sets layer_uuid to the filesystem UUID that the image must have, or to -# nothing if the token names none, and upper to scratch or ram. The parser also -# refuses a RAM upper beside a scratch block device, which this init checks -# against the kernel's devices instead, with refuse_scratch_device. The tests -# replay nvx-rs/tests/fixtures/root-tokens.json through it. +# device, nvx_scratch=0xd0006000,ext4 or nvx_overlay_upper=ramfs the upper, and +# nvx_overlay_mode, nvx_scratch_journal, nvx_first_partition_bound, and +# nvx_ram_upper_percent the root's settings, each once. This init assembles +# only an EROFS image, mounts the overlay only for trusted lowers, and reads +# nothing of a scratch's superblock, so after those rules it refuses a GPT +# image, untrusted lowers, and a forbidden journal too. A bound is checked as +# an unsigned decimal even though it needs a GPT image, which this init cannot +# assemble. Sets layer_uuid to the filesystem UUID that the image must have, or to +# nothing if the token names none, upper to scratch or ram, and ram_percent to +# the RAM upper's cap, or to nothing for the kernel's default size. The parser +# also refuses a RAM upper beside a scratch block device, which this init +# checks against the kernel's devices instead, with refuse_scratch_device. The +# tests replay nvx-rs/tests/fixtures/root-tokens.json through it. read_root_tokens() { layer_seen=false scratch_seen=false ram_seen=false layer_uuid= + # The names of the settings that the command line names, and the values of + # three of them. + settings= + overlay_mode= + scratch_journal= + ram_percent= # Kernel command-line tokens are deliberately split on spaces. # shellcheck disable=SC2086 set -f for token in $cmdline; do case "$token" in - nvx_layer | nvx_scratch | nvx_overlay_upper) + nvx_layer | nvx_scratch | nvx_overlay_upper | nvx_overlay_mode | nvx_scratch_journal | nvx_first_partition_bound | nvx_ram_upper_percent) fatal "the $token kernel parameter has no value" ;; nvx_layer=*) @@ -252,6 +264,38 @@ read_root_tokens() { [ "${token#*=}" = ramfs ] || fatal "the nvx_overlay_upper kernel parameter must be ramfs, not '${token#*=}'" ;; + nvx_overlay_mode=* | nvx_scratch_journal=* | nvx_first_partition_bound=* | nvx_ram_upper_percent=*) + setting=${token%%=*} + case " $settings " in *" $setting "*) fatal "the $setting kernel parameter is repeated" ;; esac + settings="$settings $setting" + case "$token" in + nvx_overlay_mode=trusted-lowers | nvx_overlay_mode=untrusted-lowers) overlay_mode=${token#*=} ;; + nvx_overlay_mode=*) fatal "the $setting kernel parameter must be trusted-lowers or untrusted-lowers, not '${token#*=}'" ;; + nvx_scratch_journal=allowed | nvx_scratch_journal=forbidden) scratch_journal=${token#*=} ;; + nvx_scratch_journal=*) fatal "the $setting kernel parameter must be allowed or forbidden, not '${token#*=}'" ;; + nvx_ram_upper_percent=[1-9] | nvx_ram_upper_percent=[1-8][0-9] | nvx_ram_upper_percent=90) ram_percent=${token#*=} ;; + nvx_ram_upper_percent=*) fatal "the $setting kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '${token#*=}'" ;; + nvx_first_partition_bound= | nvx_first_partition_bound=0* | nvx_first_partition_bound=*[!0-9]*) fatal "the $setting kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '${token#*=}'" ;; + nvx_first_partition_bound=*) + bound_digits=${token#*=} + [ "${#bound_digits}" -le 20 ] || + fatal "the $setting kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '${token#*=}'" + if [ "${#bound_digits}" -eq 20 ]; then + bound_limit=18446744073709551615 + # Compare digits, never the full unsigned value. + while [ -n "$bound_digits" ]; do + bound_digit=${bound_digits%"${bound_digits#?}"} + limit_digit=${bound_limit%"${bound_limit#?}"} + [ "$bound_digit" -le "$limit_digit" ] || + fatal "the $setting kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '${token#*=}'" + [ "$bound_digit" -eq "$limit_digit" ] || break + bound_digits=${bound_digits#?} + bound_limit=${bound_limit#?} + done + fi + ;; + esac + ;; esac done set +f @@ -266,6 +310,15 @@ read_root_tokens() { else fatal "the nvx_layer kernel parameter needs an upper, nvx_scratch or nvx_overlay_upper" fi + [ -z "$scratch_journal" ] || [ "$upper" = scratch ] || + fatal "the nvx_scratch_journal kernel parameter needs a scratch upper, nvx_scratch" + [ -z "$ram_percent" ] || [ "$upper" = ram ] || + fatal "the nvx_ram_upper_percent kernel parameter needs a RAM upper, nvx_overlay_upper" + case " $settings " in *" nvx_first_partition_bound "*) fatal "the nvx_first_partition_bound kernel parameter needs a GPT image" ;; esac + [ "$overlay_mode" != untrusted-lowers ] || + fatal "nvx_overlay_mode=untrusted-lowers names untrusted lowers, which this init cannot assemble: it mounts the overlay only for trusted lowers, with metacopy=on,xino=on" + [ "$scratch_journal" != forbidden ] || + fatal "nvx_scratch_journal=forbidden forbids the scratch a journal, which this init cannot check: it reads nothing of the scratch's superblock" } # Fails if the kernel has a virtio-mmio device at the scratch block device's @@ -288,11 +341,11 @@ refuse_scratch_device() { # Mounts the upper that read_root_tokens read, and creates the overlay's upper # and work directories on it: the ext4 scratch disk at 0xd0006000, or a tmpfs, # with the mode of an ext4 scratch's root rather than tmpfs's world-writable -# default, and with the kernel's default size, half of the guest's RAM, as -# nvx::guest::root mounts them. Sets upper_point to the upper's mount point, -# and upper_name to what messages call it. A tmpfs upper's pages are charged to -# the memory cgroup of the process that writes them, so the workload's writes -# count against its nvx_memory_max. +# default, and with the size of its cap, size=N%, or else the kernel's default +# size, half of the guest's RAM, as nvx::guest::root mounts them. Sets +# upper_point to the upper's mount point, and upper_name to what messages call +# it. A tmpfs upper's pages are charged to the memory cgroup of the process +# that writes them, so the workload's writes count against its nvx_memory_max. mount_upper() { case "$upper" in scratch) @@ -310,7 +363,7 @@ mount_upper() { upper_point=$tmpfs upper_name="the tmpfs upper" mkdir -p "$upper_point" - mount -t tmpfs -o mode=0755,nosuid,nodev tmpfs "$upper_point" || + mount -t tmpfs -o "mode=0755,nosuid,nodev${ram_percent:+,size=$ram_percent%}" tmpfs "$upper_point" || fatal "failed to mount the tmpfs upper" ;; esac diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 31b418906..a9250e186 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -112,7 +112,7 @@ changes only when the module's purpose does. | [`nvx::protocol::export`](#nvxprotocolexport) | The boot tokens of the host export, the virtio-fs device from which a guest maps host paths, and the guest's parser of them | | [`nvx::protocol::nvxs`](#nvxprotocolnvxs) | The NVXS session, independent of any application protocol: its records and the state machines of its host and guest roles | | [`nvx::protocol::restore_ready`](#nvxprotocolrestore_ready) | The exact parser of OpenVMM's restore-ready event, including its terminating newline | -| [`nvx::protocol::root`](#nvxprotocolroot) | The boot tokens that tell the guest which image its layered root assembles and where the root keeps its upper | +| [`nvx::protocol::root`](#nvxprotocolroot) | The boot tokens that tell the guest which image its layered root assembles, where the root keeps its upper, and how the guest assembles them | | [`nvx::protocol::share`](#nvxprotocolshare) | The guest targets that a sandbox's live shares may not take, which the host and the guest both refuse | | [`nvx::protocol::status`](#nvxprotocolstatus) | The exit statuses with which a clone's guest ends its VM once it fails fast, and what each means | | [`nvx::activation`](#nvxactivation) | The activation record of a layered root, which a clone reads in place of its captured configuration, and what Ready reports | @@ -247,16 +247,25 @@ trailing bytes fail with `ProtocolError`. The boot tokens of a layered root, which the host renders and guests parse by the same rules: a `RootLayout` is a `RootImage` on the `distro` block device, an EROFS filesystem or a GPT disk with the `nvx::uuid::Uuid` that the guest expects -of it, if any, over a `RootUpper`, the ext4 scratch disk or RAM. +of it, if any, over a `RootUpper`, the ext4 scratch disk or RAM, with the +settings of the root's assembly that it names: the overlay's `OverlayMode` and +a scratch's `Journal` policy, both of `nvx::activation`, a GPT disk's +first-partition bound, and a RAM upper's cap, from 1 to 90 percent of the VM's +memory. The guest takes its own default for a setting that the layout leaves +out. `RootLayout::tokens` renders `nvx_layer=distro,0xd0003000,FORMAT[:IDENTITY]` and -`nvx_scratch=0xd0006000,ext4` or `nvx_overlay_upper=ramfs`, and +`nvx_scratch=0xd0006000,ext4` or `nvx_overlay_upper=ramfs`, then +`nvx_overlay_mode=trusted-lowers` or `untrusted-lowers`, +`nvx_scratch_journal=allowed` or `forbidden`, `nvx_first_partition_bound=BYTES`, +and `nvx_ram_upper_percent=PERCENT` for the settings that it names, and `RootLayout::parse` reads them from a kernel command line, and refuses -malformed, repeated, and conflicting tokens and the earlier form without a -format, with no fallback that probes the devices. It reads the virtio-mmio -devices of the command line with `nvx::protocol::cmdline`, to refuse a RAM upper -beside one at the scratch base. `tests/fixtures/root-tokens.json` holds its -cases, which the tests of the guest's shell init replay too. +malformed, repeated, and conflicting tokens, a setting of an upper or a format +that the layout lacks, and the earlier form without a format, with no fallback +that probes the devices. It reads the virtio-mmio devices of the command line +with `nvx::protocol::cmdline`, to refuse a RAM upper beside one at the scratch +base. `tests/fixtures/root-tokens.json` holds its cases, which the tests of the +guest's shell init replay too. ### `nvx::protocol::share` @@ -1708,8 +1717,10 @@ option set of the caller's `OverlayMode`, `metacopy=on,xino=on` or, for lowers that may carry untrusted overlay xattrs, `metacopy=off,redirect_dir=nofollow`, with no fallback, whose upper and work directories are on the `Upper`: an ext4 scratch mounted `nosuid,nodev`, whose superblock it checks for a journal that -the caller's `Journal` policy forbids, or a tmpfs mounted `nosuid,nodev` at the -kernel's default size. It refuses overlay options longer than +the caller's `Journal` policy forbids, or a tmpfs mounted `nosuid,nodev` with +`size=N%` for the caller's cap of N percent of the guest's memory, which it +refuses outside 1 to 90 before anything changes, or else at the kernel's +default size. It refuses overlay options longer than `MOUNT_OPTIONS_LIMIT`, the most that a mount passes, which, below `/run/nvx`, fit up to 195 layers of a GPT disk, and an overlay that the kernel's mount table shows read-only or without the mode's options in effect, since the kernel mounts diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index c917686dd..137011df4 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -1414,6 +1414,8 @@ field nvx::guest::root::Error::Size::actual (hidden) field nvx::guest::root::Error::Size::device (hidden) field nvx::guest::root::Error::Size::expected (hidden) field nvx::guest::root::Error::Size::role (hidden) +variant nvx::guest::root::Error::TmpfsCap (hidden) +field nvx::guest::root::Error::TmpfsCap::percent (hidden) variant nvx::guest::root::Error::Uuid (hidden) field nvx::guest::root::Error::Uuid::actual (hidden) field nvx::guest::root::Error::Uuid::expected (hidden) @@ -1525,6 +1527,7 @@ variant nvx::guest::root::Upper::Scratch (hidden) field nvx::guest::root::Upper::Scratch::device (hidden) field nvx::guest::root::Upper::Scratch::journal (hidden) variant nvx::guest::root::Upper::Tmpfs (hidden) +field nvx::guest::root::Upper::Tmpfs::max_memory_percent (hidden) method nvx::guest::root::Upper::kind (hidden) enum nvx::guest::root::UpperKind (hidden) variant nvx::guest::root::UpperKind::Scratch (hidden) @@ -3562,12 +3565,15 @@ mod nvx::protocol::restore_ready fn nvx::protocol::restore_ready::parse_ready_event mod nvx::protocol::root const nvx::protocol::root::DISTRO_MMIO_BASE +const nvx::protocol::root::FIRST_PARTITION_BOUND_TOKEN enum nvx::protocol::root::ImageFormat variant nvx::protocol::root::ImageFormat::Erofs variant nvx::protocol::root::ImageFormat::Gpt method nvx::protocol::root::ImageFormat::as_str method nvx::protocol::root::ImageFormat::from_name const nvx::protocol::root::LAYER_TOKEN +const nvx::protocol::root::OVERLAY_MODE_TOKEN +const nvx::protocol::root::RAM_UPPER_PERCENT_TOKEN const nvx::protocol::root::RAM_UPPER_TOKEN struct nvx::protocol::root::RootImage method nvx::protocol::root::RootImage::erofs @@ -3575,7 +3581,11 @@ field nvx::protocol::root::RootImage::format method nvx::protocol::root::RootImage::gpt field nvx::protocol::root::RootImage::identity struct nvx::protocol::root::RootLayout +field nvx::protocol::root::RootLayout::gpt_first_partition_bound field nvx::protocol::root::RootLayout::image +field nvx::protocol::root::RootLayout::journal +field nvx::protocol::root::RootLayout::max_memory_percent +field nvx::protocol::root::RootLayout::overlay method nvx::protocol::root::RootLayout::parse method nvx::protocol::root::RootLayout::tokens field nvx::protocol::root::RootLayout::upper @@ -3583,6 +3593,7 @@ enum nvx::protocol::root::RootUpper variant nvx::protocol::root::RootUpper::Ram variant nvx::protocol::root::RootUpper::Scratch method nvx::protocol::root::RootUpper::as_str +const nvx::protocol::root::SCRATCH_JOURNAL_TOKEN const nvx::protocol::root::SCRATCH_MMIO_BASE const nvx::protocol::root::SCRATCH_TOKEN mod nvx::protocol::share diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index 5396206e5..10a1af6c6 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -2487,12 +2487,15 @@ mod nvx::protocol::restore_ready fn nvx::protocol::restore_ready::parse_ready_event mod nvx::protocol::root const nvx::protocol::root::DISTRO_MMIO_BASE +const nvx::protocol::root::FIRST_PARTITION_BOUND_TOKEN enum nvx::protocol::root::ImageFormat variant nvx::protocol::root::ImageFormat::Erofs variant nvx::protocol::root::ImageFormat::Gpt method nvx::protocol::root::ImageFormat::as_str method nvx::protocol::root::ImageFormat::from_name const nvx::protocol::root::LAYER_TOKEN +const nvx::protocol::root::OVERLAY_MODE_TOKEN +const nvx::protocol::root::RAM_UPPER_PERCENT_TOKEN const nvx::protocol::root::RAM_UPPER_TOKEN struct nvx::protocol::root::RootImage method nvx::protocol::root::RootImage::erofs @@ -2500,7 +2503,11 @@ field nvx::protocol::root::RootImage::format method nvx::protocol::root::RootImage::gpt field nvx::protocol::root::RootImage::identity struct nvx::protocol::root::RootLayout +field nvx::protocol::root::RootLayout::gpt_first_partition_bound field nvx::protocol::root::RootLayout::image +field nvx::protocol::root::RootLayout::journal +field nvx::protocol::root::RootLayout::max_memory_percent +field nvx::protocol::root::RootLayout::overlay method nvx::protocol::root::RootLayout::parse method nvx::protocol::root::RootLayout::tokens field nvx::protocol::root::RootLayout::upper @@ -2508,6 +2515,7 @@ enum nvx::protocol::root::RootUpper variant nvx::protocol::root::RootUpper::Ram variant nvx::protocol::root::RootUpper::Scratch method nvx::protocol::root::RootUpper::as_str +const nvx::protocol::root::SCRATCH_JOURNAL_TOKEN const nvx::protocol::root::SCRATCH_MMIO_BASE const nvx::protocol::root::SCRATCH_TOKEN mod nvx::protocol::share diff --git a/nvx-rs/src/guest/clone/activation/apply.rs b/nvx-rs/src/guest/clone/activation/apply.rs index 96247341b..ba919cea4 100644 --- a/nvx-rs/src/guest/clone/activation/apply.rs +++ b/nvx-rs/src/guest/clone/activation/apply.rs @@ -956,7 +956,10 @@ fn assembly(template: &RootTemplate, configuration: &Configuration) -> Assembly }, }; let upper = match configuration.upper { - activation::Upper::Tmpfs => root::Upper::Tmpfs, + // The record names no cap, so the tmpfs takes the kernel's default size. + activation::Upper::Tmpfs => root::Upper::Tmpfs { + max_memory_percent: None, + }, activation::Upper::Scratch { journal, .. } => root::Upper::Scratch { device: template.scratch, journal: match journal { diff --git a/nvx-rs/src/guest/clone/activation/apply/tests.rs b/nvx-rs/src/guest/clone/activation/apply/tests.rs index b3b02609d..a66827faf 100644 --- a/nvx-rs/src/guest/clone/activation/apply/tests.rs +++ b/nvx-rs/src/guest/clone/activation/apply/tests.rs @@ -632,7 +632,9 @@ fn accepted_assembly() -> Assembly { image: root::Image::Erofs { uuid: Some(IMAGE_UUID), }, - upper: root::Upper::Tmpfs, + upper: root::Upper::Tmpfs { + max_memory_percent: None, + }, overlay: root::OverlayMode::UntrustedLowers, timeout: template.timeout, } @@ -929,8 +931,14 @@ fn assembly_inputs_are_taken_exactly_from_the_record() { (gpt(None), gpt_image(None), true), (gpt(Some(DISK_GUID)), gpt_image(Some(DISK_GUID)), true), ]; + // The record names no cap of a tmpfs, which then takes the kernel's default size. let uppers = [ - (activation::Upper::Tmpfs, root::Upper::Tmpfs), + ( + activation::Upper::Tmpfs, + root::Upper::Tmpfs { + max_memory_percent: None, + }, + ), ( scratch(activation::Journal::Allowed), root::Upper::Scratch { diff --git a/nvx-rs/src/guest/root.rs b/nvx-rs/src/guest/root.rs index abebcdf72..dccf02b95 100644 --- a/nvx-rs/src/guest/root.rs +++ b/nvx-rs/src/guest/root.rs @@ -152,7 +152,7 @@ //! expected: Expected::new().disk_guid("8d2f3a4e-1c5b-4e7a-9f60-2b3c4d5e6f70".parse()?), //! first_partition_bound: 64 * 1024, //! }, -//! upper: Upper::Tmpfs, +//! upper: Upper::Tmpfs { max_memory_percent: Some(50) }, //! overlay: OverlayMode::UntrustedLowers, //! timeout: Duration::from_secs(30), //! })?; @@ -194,6 +194,7 @@ use super::gpt::DeviceError; use super::mappings::{Kind, Mapper, MappingError, TargetRoot, below_its_root}; use super::sys::{self, Errno, FileDescriptor, FileSystem, Mode, MountFlags, OpenFlags}; use crate::gpt::{Expected, GptError, Partition}; +use crate::protocol::root::RAM_UPPER_CAPS; use crate::uuid::Uuid; pub(crate) use self::block::window_directories; @@ -235,7 +236,8 @@ const WORK_DIRECTORY: &str = "work"; /// root directory takes the upper's mode, and a workload that is not root must enter its root. const SCRATCH_DIRECTORY_MODE: u32 = 0o755; /// The tmpfs upper's options: the mode of its root directory, which an ext4 scratch's root has -/// too, rather than tmpfs's world-writable default. Its size is the kernel's default. +/// too, rather than tmpfs's world-writable default. An assembly adds the size of a tmpfs that has +/// a cap, `size=N%`, and the kernel's default size is that of one that has none. const TMPFS_OPTIONS: &[u8] = b"mode=0755"; /// Do not load a GPT layer's journal: even without `needs_recovery`, loading can scan a corrupt /// journal indefinitely. `check_layer` still refuses recovery-required layers. Override the @@ -411,9 +413,13 @@ pub enum Upper { /// Whether the file system may have a journal. journal: Journal, }, - /// A tmpfs at the kernel's default size, which an assembly mounts `nosuid,nodev` at `tmpfs`, - /// for a VM that has no scratch device. - Tmpfs, + /// A tmpfs, which an assembly mounts `nosuid,nodev` at `tmpfs`, for a VM that has no scratch + /// device. + Tmpfs { + /// The tmpfs's cap, in percent of the guest's memory, from 1 to 90, which an assembly + /// mounts it with as `size=N%`, or `None` for the kernel's default size, half of it. + max_memory_percent: Option, + }, } impl Upper { @@ -421,7 +427,7 @@ impl Upper { pub const fn kind(self) -> UpperKind { match self { Self::Scratch { .. } => UpperKind::Scratch, - Self::Tmpfs => UpperKind::Tmpfs, + Self::Tmpfs { .. } => UpperKind::Tmpfs, } } } @@ -477,7 +483,8 @@ impl OverlayMode { /// Assembles the layered root of `assembly`, as the [module](self) describes, and returns the /// root that owns its mounts. It checks everything before it mounts anything. In order, it: /// -/// 1. checks the runtime directory's path; +/// 1. checks the runtime directory's path, and that a tmpfs upper's cap, if any, is from 1 to 90 +/// percent of the guest's memory, since the kernel reads a cap of 0 as none; /// 2. creates the runtime directory, as `mkdir -p` does, and refuses it if a symbolic link leads /// through it; /// 3. creates the mount points of an EROFS image, `layers/distro`, of the upper, `scratch` or @@ -504,7 +511,7 @@ impl OverlayMode { /// 8. mounts the layers: an EROFS image read-only, or a GPT disk's ext4 layers read-only with /// `nosuid` and `nodev`; /// 9. mounts the upper with `nosuid` and `nodev`: the scratch's ext4 file system, or a tmpfs -/// whose root directory has mode 0755; +/// whose root directory has mode 0755, with the size of its cap, if any; /// 10. creates the upper's `upper` and `work` directories unless they exist, with mode 0755 /// whatever the process's umask, since the overlay's root directory takes the upper's mode; /// 11. mounts the overlay at `rootfs`, `nosuid` and `nodev`, with the layers as its lower @@ -616,6 +623,16 @@ fn assemble_with( cleanup: None, }); } + if let Upper::Tmpfs { + max_memory_percent: Some(percent), + } = assembly.upper + && !RAM_UPPER_CAPS.contains(&percent) + { + return Err(AssemblyError { + error: Error::TmpfsCap { percent }, + cleanup: None, + }); + } let mut root = Root { kernel, runtime: assembly.runtime.clone(), @@ -1101,7 +1118,7 @@ impl Root { } Some(device) } - Upper::Tmpfs => None, + Upper::Tmpfs { .. } => None, }; let upper = upper_point.join(UPPER_DIRECTORY); let work = upper_point.join(WORK_DIRECTORY); @@ -1140,13 +1157,20 @@ impl Root { Mount::Scratch } None => { + let mut options = TMPFS_OPTIONS.to_vec(); + if let Upper::Tmpfs { + max_memory_percent: Some(percent), + } = assembly.upper + { + options.extend(format!(",size={percent}%").bytes()); + } self.mount_at( &Mount::Tmpfs, Some(b"tmpfs"), &upper_point, Some(b"tmpfs"), flags, - Some(TMPFS_OPTIONS), + Some(&options), )?; Mount::Tmpfs } @@ -1832,6 +1856,11 @@ pub enum Error { /// The runtime directory. path: PathBuf, }, + /// A tmpfs upper's cap is not 1 to 90 percent of the guest's memory. + TmpfsCap { + /// The cap, in percent of the guest's memory. + percent: u8, + }, /// A mount point or a directory below one could not be created or checked for a mount, or /// the runtime directory could not be locked. MountPoint { @@ -2029,6 +2058,10 @@ impl fmt::Display for Error { `=`, or a symbolic link: {}", path.display() ), + Self::TmpfsCap { percent } => write!( + formatter, + "the tmpfs upper's cap of {percent}% of the guest's memory is not 1 to 90%" + ), Self::MountPoint { path, error } => { write!(formatter, "failed to prepare {}: {error}", path.display()) } diff --git a/nvx-rs/src/guest/root/tests.rs b/nvx-rs/src/guest/root/tests.rs index 8b3fdf851..f116c2cc9 100644 --- a/nvx-rs/src/guest/root/tests.rs +++ b/nvx-rs/src/guest/root/tests.rs @@ -225,6 +225,11 @@ fn upper_point(kind: UpperKind) -> &'static str { } } +/// The tmpfs upper without a cap, at the kernel's default size. +const TMPFS: Upper = Upper::Tmpfs { + max_memory_percent: None, +}; + /// Returns the upper on the guest's scratch device, with `journal`. fn scratch_upper(journal: Journal) -> Upper { Upper::Scratch { @@ -946,7 +951,7 @@ fn new_upper_and_work_directories_have_mode_0755_whatever_the_umask() { // So are a tmpfs upper's, which are always new. let guest = Guest::new(); let assembly = Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, ..guest.assembly() }; guest.assemble(&assembly).unwrap().teardown().unwrap(); @@ -959,7 +964,7 @@ fn a_fresh_upper_s_directories_must_be_missing_or_empty() { let mode = |path: &Path| fs::metadata(path).unwrap().mode() & 0o7777; // A scratch or a tmpfs without the directories gets them, as any root's upper does, with no // other call than a cold root's. - for upper in [scratch_upper(Journal::Allowed), Upper::Tmpfs] { + for upper in [scratch_upper(Journal::Allowed), TMPFS] { let guest = Guest::new(); let assembly = Assembly { upper, @@ -1272,8 +1277,8 @@ fn a_runtime_directory_where_a_file_system_is_mounted_is_refused() { // So does an upper that a failed unmount left, whichever upper the next root mounts. for (first, second) in [ - (scratch_upper(Journal::Allowed), Upper::Tmpfs), - (Upper::Tmpfs, scratch_upper(Journal::Allowed)), + (scratch_upper(Journal::Allowed), TMPFS), + (TMPFS, scratch_upper(Journal::Allowed)), ] { let guest = Guest::new(); let left = guest.path(&format!("run/nvx/{}", upper_point(first.kind()))); @@ -1586,8 +1591,25 @@ fn a_gpt_disk_s_layers_are_mounted_under_the_overlay_top_first() { #[test] fn both_overlay_modes_assemble_on_both_uppers_with_their_own_option_sets() { let modes = [OverlayMode::TrustedLowers, OverlayMode::UntrustedLowers]; + // Each upper with the options of its tmpfs, if it is one: a tmpfs with a cap takes its size. + let uppers = [ + (scratch_upper(Journal::Allowed), None), + (TMPFS, Some("mode=0755")), + ( + Upper::Tmpfs { + max_memory_percent: Some(1), + }, + Some("mode=0755,size=1%"), + ), + ( + Upper::Tmpfs { + max_memory_percent: Some(90), + }, + Some("mode=0755,size=90%"), + ), + ]; for gpt in [false, true] { - for upper in [scratch_upper(Journal::Allowed), Upper::Tmpfs] { + for (upper, tmpfs_options) in uppers { for mode in modes { let case = format!("GPT {gpt}, {upper:?}, {mode:?}"); let guest = Guest::new(); @@ -1627,7 +1649,7 @@ fn both_overlay_modes_assemble_on_both_uppers_with_their_own_option_sets() { &format!("{runtime}/tmpfs"), Some("tmpfs"), flags, - Some("mode=0755"), + tmpfs_options, ), ), }; @@ -1677,13 +1699,24 @@ fn a_tmpfs_upper_needs_no_scratch_device() { let guest = Guest::bare(); attach(&guest.base, 0, "vda", 4096); let assembly = Assembly { - upper: Upper::Tmpfs, + upper: Upper::Tmpfs { + max_memory_percent: Some(50), + }, ..guest.assembly() }; let root = guest.assemble(&assembly).unwrap(); // The assembly waited for no other device, and made no mount point for a scratch. assert_eq!(guest.kernel.sleeps(), 0); assert!(!guest.path("run/nvx/scratch").exists()); + // It mounted the tmpfs with its cap. + let tmpfs = mount_call( + Some("tmpfs"), + &guest.text("run/nvx/tmpfs"), + Some("tmpfs"), + MountFlags::MS_NOSUID | MountFlags::MS_NODEV, + Some("mode=0755,size=50%"), + ); + assert!(guest.kernel.calls().contains(&tmpfs)); root.teardown().unwrap(); assert_eq!( guest.kernel.unmounts(), @@ -1694,10 +1727,37 @@ fn a_tmpfs_upper_needs_no_scratch_device() { ); } +#[test] +fn a_tmpfs_upper_refuses_a_cap_that_is_not_1_to_90_percent() { + // The kernel reads a cap of 0 as none, and the boot tokens carry caps from 1 to 90 percent. + let guest = Guest::new(); + for percent in [0, 91, u8::MAX] { + let assembly = Assembly { + upper: Upper::Tmpfs { + max_memory_percent: Some(percent), + }, + ..guest.assembly() + }; + let error = guest.assemble(&assembly).unwrap_err(); + assert!( + matches!(error.error(), Error::TmpfsCap { percent: cap } if *cap == percent), + "{percent}: {error:?}" + ); + assert!(error.cleanup().is_none()); + assert_eq!( + error.to_string(), + format!("the tmpfs upper's cap of {percent}% of the guest's memory is not 1 to 90%") + ); + } + // It refused them before it changed anything. + assert!(guest.kernel.calls().is_empty()); + assert!(!guest.path("run/nvx/layers").exists()); +} + #[test] fn a_refused_option_set_fails_without_a_fallback() { for mode in [OverlayMode::TrustedLowers, OverlayMode::UntrustedLowers] { - for upper in [scratch_upper(Journal::Allowed), Upper::Tmpfs] { + for upper in [scratch_upper(Journal::Allowed), TMPFS] { let guest = Guest::new(); guest.kernel.fail( Operation::Mount, @@ -1753,7 +1813,7 @@ fn an_overlay_that_the_kernel_mounts_without_its_mode_in_effect_is_refused_and_u // Without failing the mount, the kernel drops `metacopy=on` for an upper that can't hold the // overlay's extended attributes. Its mount table then shows no `metacopy`, which is off by // default. - for upper in [scratch_upper(Journal::Allowed), Upper::Tmpfs] { + for upper in [scratch_upper(Journal::Allowed), TMPFS] { let guest = Guest::new(); guest.show_overlay("rw", upper.kind(), "xino=on"); let assembly = Assembly { @@ -1810,7 +1870,7 @@ fn options_that_the_kernel_does_not_show_have_their_defaults() { // The kernel's mount table shows no option at its default, as for each option of the // untrusted lowers under the guest's kernel, whose defaults are off. let untrusted = |guest: &Guest| Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, overlay: OverlayMode::UntrustedLowers, ..guest.assembly() }; @@ -1905,7 +1965,7 @@ fn effective_options_that_cannot_be_read_fail_the_assembly() { } guest.show_overlay("rw", UpperKind::Tmpfs, ""); let assembly = Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, overlay: OverlayMode::UntrustedLowers, ..guest.assembly() }; @@ -1953,7 +2013,7 @@ fn overlay_options_that_a_mount_cannot_pass_are_refused_before_any_mount() { let runtime = guest.path(&format!("run/{}/nvx", [component.as_str(); 6].join("/"))); let assembly = Assembly { runtime: runtime.clone(), - upper: Upper::Tmpfs, + upper: TMPFS, ..guest.assembly() }; let error = guest.assemble(&assembly).unwrap_err(); @@ -2006,7 +2066,7 @@ fn up_to_195_layers_fit_in_the_overlay_s_options_below_run_nvx() { guest.insert(&GptDisk::new(b"configuration", 254)); let mode = OverlayMode::UntrustedLowers; let error = guest - .assemble(&guest.gpt_assembly(Upper::Tmpfs, mode)) + .assemble(&guest.gpt_assembly(TMPFS, mode)) .unwrap_err(); let layers: Vec = (2..=255) .rev() @@ -2165,7 +2225,7 @@ fn a_device_whose_access_mode_is_not_its_role_s_is_refused_before_it_is_flushed( fn a_tmpfs_upper_is_unmounted_last_and_every_failure_reported() { let guest = Guest::new(); let assembly = Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, overlay: OverlayMode::UntrustedLowers, ..guest.assembly() }; @@ -2226,7 +2286,7 @@ fn a_scratch_s_file_system_is_synced_right_before_its_unmount() { // A tmpfs upper has no device to sync to. let guest = Guest::new(); let assembly = Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, ..guest.assembly() }; let root = guest.assemble(&assembly).unwrap(); @@ -2285,7 +2345,7 @@ fn a_gpt_disk_that_differs_from_the_expected_identities_is_refused_before_any_mo // The disk meets every expectation that the caller may set. let guest = Guest::new(); guest.insert(&disk); - let mut assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::UntrustedLowers); + let mut assembly = guest.gpt_assembly(TMPFS, OverlayMode::UntrustedLowers); assembly.image = Image::Gpt { expected: Expected::new() .disk_guid(DISK_GUID) @@ -2338,7 +2398,7 @@ fn a_gpt_disk_that_differs_from_the_expected_identities_is_refused_before_any_mo for (expected, failure) in cases { let guest = Guest::new(); guest.insert(&disk); - let mut assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::UntrustedLowers); + let mut assembly = guest.gpt_assembly(TMPFS, OverlayMode::UntrustedLowers); assembly.image = Image::Gpt { expected, first_partition_bound: FIRST_PARTITION_BOUND, @@ -2365,7 +2425,7 @@ fn a_gpt_disk_that_differs_from_the_expected_identities_is_refused_before_any_mo fn a_gpt_disk_without_a_layer_is_refused_before_any_mount() { let guest = Guest::new(); guest.insert(&GptDisk::new(b"configuration", 0)); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!( @@ -2396,7 +2456,7 @@ fn a_layer_that_is_not_ext4_or_whose_journal_needs_a_recovery_is_refused_before_ disk.layer_mut(2)[1024 + 0x5C] |= 0x04; let guest = Guest::new(); guest.insert(&disk); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); guest.assemble(&assembly).unwrap().teardown().unwrap(); // But not one that needs recovery, despite our `noload` or the superblock's own defaults. @@ -2405,7 +2465,7 @@ fn a_layer_that_is_not_ext4_or_whose_journal_needs_a_recovery_is_refused_before_ layer[1024 + 0x200..][..10].copy_from_slice(b"norecovery"); let guest = Guest::new(); guest.insert(&disk); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!(error.error(), Error::Recovery { number: 3, device } @@ -2427,7 +2487,7 @@ fn a_layer_that_is_not_ext4_or_whose_journal_needs_a_recovery_is_refused_before_ disk.layer_mut(2)[1024 + 0x38] ^= 1; let guest = Guest::new(); guest.insert(&disk); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!(error.error(), Error::NotExt4Layer { number: 2, device } @@ -2448,7 +2508,7 @@ fn a_layer_that_is_not_ext4_or_whose_journal_needs_a_recovery_is_refused_before_ let guest = Guest::new(); fs::write(guest.path("dev/vda"), disk.image()).unwrap(); fs::create_dir(guest.path("dev/vda2")).unwrap(); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!( @@ -2474,7 +2534,7 @@ fn a_missing_partition_fails_at_once_before_any_mount() { let guest = Guest::new(); fs::write(guest.path("dev/vda"), disk.image()).unwrap(); write(&guest.path("dev/vda2"), disk.layer(2)); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!(error.error(), Error::Partition { number: 3, node } @@ -2748,7 +2808,7 @@ fn the_first_partition_is_returned_as_opaque_bytes_within_its_bound() { let guest = Guest::new(); let disk = GptDisk::new(bytes, 1); guest.insert(&disk); - let mut assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let mut assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); // The first partition may be as large as the bound. assembly.image = Image::Gpt { expected: Expected::new(), @@ -2766,7 +2826,7 @@ fn the_first_partition_is_returned_as_opaque_bytes_within_its_bound() { // A first partition larger than the bound is refused before any mount. let guest = Guest::new(); guest.insert(&GptDisk::new(§or, 1)); - let mut assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let mut assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); assembly.image = Image::Gpt { expected: Expected::new(), first_partition_bound: BLOCK - 1, @@ -2800,7 +2860,7 @@ fn the_first_partition_is_returned_as_opaque_bytes_within_its_bound() { fn a_teardown_unmounts_the_layers_top_first_and_reports_every_failure() { let guest = Guest::new(); guest.insert(&GptDisk::new(b"configuration", 3)); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::UntrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::UntrustedLowers); let root = guest.assemble(&assembly).unwrap(); for name in ["run/nvx/layers/p3", "run/nvx/tmpfs"] { guest @@ -2846,7 +2906,7 @@ fn a_teardown_unmounts_the_layers_top_first_and_reports_every_failure() { guest.path("run/nvx/layers/p3"), Errno::EINVAL, ); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::UntrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::UntrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!( @@ -2872,12 +2932,12 @@ fn a_layer_that_a_failed_unmount_left_keeps_the_runtime_directory_occupied() { guest .kernel .fail(Operation::Unmount, top.clone(), Errno::EBUSY); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); guest.assemble(&assembly).unwrap().teardown().unwrap_err(); guest.insert(&GptDisk::new(b"configuration", 1)); let (mounts, calls) = (guest.kernel.mounts().len(), guest.kernel.calls()); let erofs = Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, ..guest.assembly() }; for assembly in [erofs, assembly] { @@ -2897,13 +2957,13 @@ fn a_layer_that_a_failed_unmount_left_keeps_the_runtime_directory_occupied() { .kernel .fail(Operation::Unmount, distro.clone(), Errno::EBUSY); let assembly = Assembly { - upper: Upper::Tmpfs, + upper: TMPFS, ..guest.assembly() }; guest.assemble(&assembly).unwrap().teardown().unwrap_err(); guest.insert(&GptDisk::new(b"configuration", 1)); let (mounts, calls) = (guest.kernel.mounts().len(), guest.kernel.calls()); - let assembly = guest.gpt_assembly(Upper::Tmpfs, OverlayMode::TrustedLowers); + let assembly = guest.gpt_assembly(TMPFS, OverlayMode::TrustedLowers); let error = guest.assemble(&assembly).unwrap_err(); assert!( matches!(error.error(), Error::Occupied { path } if *path == distro), diff --git a/nvx-rs/src/host/sandbox/plan.rs b/nvx-rs/src/host/sandbox/plan.rs index d7f96196f..6e7ab5cd7 100644 --- a/nvx-rs/src/host/sandbox/plan.rs +++ b/nvx-rs/src/host/sandbox/plan.rs @@ -495,11 +495,16 @@ impl SandboxPlan { self.network.as_ref().and_then(SandboxNetwork::proxy_url) } - /// Returns the layout of the root that the plan boots: its image and its upper. + /// Returns the layout of the root that the plan boots: its image and its upper. It names none + /// of the root's settings, so the guest assembles the root with its own defaults. pub fn layout(&self) -> RootLayout { RootLayout { image: self.distro.image, upper: self.upper.kind(), + overlay: None, + journal: None, + gpt_first_partition_bound: None, + max_memory_percent: None, } } diff --git a/nvx-rs/src/host/sandbox/plan/tests.rs b/nvx-rs/src/host/sandbox/plan/tests.rs index da35e54d6..3e1d16a82 100644 --- a/nvx-rs/src/host/sandbox/plan/tests.rs +++ b/nvx-rs/src/host/sandbox/plan/tests.rs @@ -172,11 +172,16 @@ fn every_format_and_upper_plans_and_renders() { for (image, upper, tokens) in cases { let (image, upper) = (images()[image], uppers()[upper].clone()); let plan = plan(image, upper.clone(), settings.clone()); + // The plan names none of the root's settings. assert_eq!( plan.layout(), RootLayout { image, - upper: upper.kind() + upper: upper.kind(), + overlay: None, + journal: None, + gpt_first_partition_bound: None, + max_memory_percent: None, } ); assert_eq!( @@ -1367,15 +1372,12 @@ fn adapter_plans_render_their_root_and_devices_alone() { let plan = adapter(image, upper.clone()); assert_eq!(plan.settings(), None); assert_eq!(plan.export(), None); - let [layer, upper_token] = plan.layout().tokens(); + let tokens = plan.layout().tokens().join(" "); assert_eq!( plan.kernel_command_line(user).unwrap(), - format!("{user} {layer} {upper_token}") - ); - assert_eq!( - plan.kernel_command_line(" \t").unwrap(), - format!("{layer} {upper_token}") + format!("{user} {tokens}") ); + assert_eq!(plan.kernel_command_line(" \t").unwrap(), tokens); // No workload identity and no shares: the block devices alone. let mut expected = vec![ "--machine".to_owned(), @@ -1467,6 +1469,32 @@ fn adapter_plans_refuse_what_their_root_and_openvmm_own() { assert_eq!(plan.clone().with_export(None).unwrap(), plan); } +#[test] +fn adapter_plans_refuse_the_settings_of_their_root_that_they_do_not_name() { + // An adapter's plan names none of its root's settings, and the guest reads a setting from the + // command line as the plan reads it back, so the plan refuses a setting that the adapter's + // command line names. + let ram = adapter(images()[3], SandboxUpper::RAM); + let scratch = adapter(images()[1], SandboxUpper::scratch("scratch.ext4")); + let hides = "the kernel command line of an adapter's plan hides the boot tokens of its root"; + for (plan, user, refusal) in [ + (&ram, "nvx_overlay_mode=untrusted-lowers", hides), + (&ram, "nvx_ram_upper_percent=5", hides), + (&ram, "x=\"y nvx_ram_upper_percent=5 z\"", hides), + (&ram, "nvx_first_partition_bound=4096", hides), + (&scratch, "nvx_scratch_journal=forbidden", hides), + ( + &scratch, + "nvx_ram_upper_percent=0", + "the kernel command line of an adapter's plan conflicts with the boot tokens of its \ + root: the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to \ + 90, without a sign or leading zeros, not '0'", + ), + ] { + assert_eq!(message(plan.kernel_command_line(user)), refusal, "{user}"); + } +} + #[test] fn plans_refuse_the_tokens_that_the_guest_init_reads_in_quoted_values() { // The kernel reads `x` with a quoted value, but the guest's init splits the command line at diff --git a/nvx-rs/src/host/sandbox/tests.rs b/nvx-rs/src/host/sandbox/tests.rs index 06255e714..cc4a999df 100644 --- a/nvx-rs/src/host/sandbox/tests.rs +++ b/nvx-rs/src/host/sandbox/tests.rs @@ -166,6 +166,10 @@ fn launch_contract_builds_agent_command_line() { RootLayout { image: RootImage::erofs(Uuid::parse("11111111-1111-1111-1111-111111111111")), upper: RootUpper::Scratch, + overlay: None, + journal: None, + gpt_first_partition_bound: None, + max_memory_percent: None, } ); assert_eq!( diff --git a/nvx-rs/src/host/template/plan/tests.rs b/nvx-rs/src/host/template/plan/tests.rs index f0cc4ef5f..3144eb883 100644 --- a/nvx-rs/src/host/template/plan/tests.rs +++ b/nvx-rs/src/host/template/plan/tests.rs @@ -547,14 +547,20 @@ fn openvmms_platform_allowlist_admits_its_tokens_and_refuses_tenant_data() { } // 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) + let [layer, upper]: [String; 2] = adapter(None).layout().tokens().try_into().unwrap(); + let [_, ram]: [String; 2] = adapter_with(gpt(), SandboxUpper::RAM, None) .layout() - .tokens(); + .tokens() + .try_into() + .unwrap(); for token in [ layer.as_str(), upper.as_str(), ram.as_str(), + "nvx_overlay_mode=untrusted-lowers", + "nvx_scratch_journal=forbidden", + "nvx_first_partition_bound=4096", + "nvx_ram_upper_percent=50", "nvx_hostname=tenant-a", "hostname=tenant-a", "nvx_entrypoint=/bin/sh", diff --git a/nvx-rs/src/protocol/root.rs b/nvx-rs/src/protocol/root.rs index 88ced8b88..a02f976b9 100644 --- a/nvx-rs/src/protocol/root.rs +++ b/nvx-rs/src/protocol/root.rs @@ -1,5 +1,6 @@ //! The boot tokens of a layered root: the kernel command-line tokens through which the host tells -//! the guest which image its layered root assembles, and where the root keeps its upper. +//! the guest which image its layered root assembles, where the root keeps its upper, and how the +//! guest assembles them. //! //! A layered root overlays one read-only image, on the `distro` block device, with a writable //! upper. Two tokens describe it, which [`RootLayout::tokens`] renders: @@ -15,20 +16,38 @@ //! [`SCRATCH_MMIO_BASE`], which the microVM fixes for their roles, and identities take the //! canonical text of a [`Uuid`]. //! +//! Four more tokens each name a setting of the root, after those two and in this order, when the +//! layout names it. Without one, the guest assembles the root with its own default, as it does for +//! a command line that predates them: +//! +//! - `nvx_overlay_mode=trusted-lowers` or `nvx_overlay_mode=untrusted-lowers` names the overlay's +//! [`OverlayMode`]; the guest's default trusts the lowers. +//! - `nvx_scratch_journal=allowed` or `nvx_scratch_journal=forbidden` names whether the ext4 +//! filesystem of a scratch upper may have a journal, its [`Journal`] policy; the guest's default +//! allows one. +//! - `nvx_first_partition_bound=BYTES` bounds the bytes of a GPT disk's first partition. +//! - `nvx_ram_upper_percent=PERCENT` caps a RAM upper at PERCENT percent of the VM's memory, from 1 +//! to 90; the guest's default is the kernel's default size of a tmpfs, half of its memory. +//! +//! A number is decimal, at least 1, without a sign or leading zeros. The journal policy needs a +//! scratch upper, the cap a RAM upper, and the bound a GPT disk. +//! //! Guests read the tokens with [`RootLayout::parse`], which accepts exactly these forms and //! refuses every other, with no fallback that probes the image or the devices. NVX's shell init -//! reads them with the same rules, and refuses the GPT image, which it cannot assemble. It finds a -//! scratch block device beside a RAM upper among the devices that the kernel added, rather than -//! on the command line. +//! reads them with the same rules, and refuses the GPT image, the untrusted lowers, and a +//! forbidden journal, which it cannot assemble. It finds a scratch block device beside a RAM upper +//! among the devices that the kernel added, rather than on the command line. //! //! The tokens belong to the boot contract that //! [`BOOT_CONTRACT_VERSION`](crate::contract::BOOT_CONTRACT_VERSION) versions, so a change to //! them changes that version too. use std::fmt; +use std::ops::RangeInclusive; use super::cmdline::{self, Token}; use super::{ProtocolError, violation}; +use crate::activation::{Journal, OverlayMode}; use crate::uuid::Uuid; /// The MMIO base of the `distro` block device, which holds a layered root's image. @@ -47,18 +66,58 @@ pub const SCRATCH_TOKEN: &str = "nvx_scratch="; /// The token that keeps a layered root's upper in RAM. pub const RAM_UPPER_TOKEN: &str = "nvx_overlay_upper=ramfs"; +/// Prefix of the token that names the overlay's mode, `nvx_overlay_mode=`. +pub const OVERLAY_MODE_TOKEN: &str = "nvx_overlay_mode="; + +/// Prefix of the token that names whether a scratch upper may have a journal, +/// `nvx_scratch_journal=`. +pub const SCRATCH_JOURNAL_TOKEN: &str = "nvx_scratch_journal="; + +/// Prefix of the token that bounds the bytes of a GPT disk's first partition, +/// `nvx_first_partition_bound=`. +pub const FIRST_PARTITION_BOUND_TOKEN: &str = "nvx_first_partition_bound="; + +/// Prefix of the token that caps a RAM upper, in percent of the VM's memory, +/// `nvx_ram_upper_percent=`. +pub const RAM_UPPER_PERCENT_TOKEN: &str = "nvx_ram_upper_percent="; + +/// The caps of a RAM upper that the tokens carry, in percent of the VM's memory. +pub(crate) const RAM_UPPER_CAPS: RangeInclusive = 1..=90; + /// The name of the kernel parameter that names the image. const LAYER: &str = "nvx_layer"; /// The name of the kernel parameter that keeps the upper on the scratch block device. const SCRATCH: &str = "nvx_scratch"; /// The name of the kernel parameter that keeps the upper in RAM. const OVERLAY_UPPER: &str = "nvx_overlay_upper"; +/// The name of the kernel parameter that names the overlay's mode. +const OVERLAY_MODE: &str = "nvx_overlay_mode"; +/// The name of the kernel parameter that names the scratch's journal policy. +const SCRATCH_JOURNAL: &str = "nvx_scratch_journal"; +/// The name of the kernel parameter that bounds a GPT disk's first partition. +const FIRST_PARTITION_BOUND: &str = "nvx_first_partition_bound"; +/// The name of the kernel parameter that caps a RAM upper. +const RAM_UPPER_PERCENT: &str = "nvx_ram_upper_percent"; +/// The names of the kernel parameters of the boot tokens. +const PARAMETERS: [&str; 7] = [ + LAYER, + SCRATCH, + OVERLAY_UPPER, + OVERLAY_MODE, + SCRATCH_JOURNAL, + FIRST_PARTITION_BOUND, + RAM_UPPER_PERCENT, +]; /// The value of the kernel parameter that keeps the upper in RAM. const RAM_UPPER: &str = "ramfs"; /// The role of the image's block device. const DISTRO_ROLE: &str = "distro"; /// The filesystem of the scratch block device. const SCRATCH_FILESYSTEM: &str = "ext4"; +/// Every overlay mode. +const OVERLAY_MODES: [OverlayMode; 2] = [OverlayMode::TrustedLowers, OverlayMode::UntrustedLowers]; +/// Every journal policy. +const JOURNALS: [Journal; 2] = [Journal::Allowed, Journal::Forbidden]; /// The format of a layered root's image, which its token names. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -158,20 +217,32 @@ impl fmt::Display for RootUpper { } } -/// The layout of a layered root: its image, on the `distro` block device, and its upper. +/// The layout of a layered root: its image, on the `distro` block device, its upper, and the +/// settings of its assembly that it names. A setting that it leaves `None` has no token, and the +/// guest assembles the root with its own default for it. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct RootLayout { /// The read-only image. pub image: RootImage, /// The upper. pub upper: RootUpper, + /// The overlay's mode. + pub overlay: Option, + /// Whether the ext4 filesystem of a [scratch](RootUpper::Scratch) upper may have a journal. + pub journal: Option, + /// The bound of the bytes of a [GPT](ImageFormat::Gpt) disk's first partition, at least 1. + pub gpt_first_partition_bound: Option, + /// The cap of a [RAM](RootUpper::Ram) upper, in percent of the VM's memory, from 1 to 90. + pub max_memory_percent: Option, } impl RootLayout { /// Returns the layout's boot tokens: the image's, `nvx_layer=distro,0xd0003000,FORMAT`, with /// `:IDENTITY` when the image has an identity, then the upper's, `nvx_scratch=0xd0006000,ext4` - /// or `nvx_overlay_upper=ramfs`. - pub fn tokens(&self) -> [String; 2] { + /// or `nvx_overlay_upper=ramfs`, then the token of each setting that the layout names, in the + /// order of the [module](self). [`parse`](Self::parse) refuses the tokens of a setting that + /// breaks its rules. + pub fn tokens(&self) -> Vec { let RootImage { format, identity } = self.image; let layer = format!("{LAYER_TOKEN}{DISTRO_ROLE},{DISTRO_MMIO_BASE:#x},{format}"); let layer = match identity { @@ -184,7 +255,19 @@ impl RootLayout { } RootUpper::Ram => RAM_UPPER_TOKEN.to_owned(), }; - [layer, upper] + let settings = [ + self.overlay + .map(|mode| format!("{OVERLAY_MODE_TOKEN}{}", overlay_name(mode))), + self.journal + .map(|journal| format!("{SCRATCH_JOURNAL_TOKEN}{}", journal_name(journal))), + self.gpt_first_partition_bound + .map(|bound| format!("{FIRST_PARTITION_BOUND_TOKEN}{bound}")), + self.max_memory_percent + .map(|percent| format!("{RAM_UPPER_PERCENT_TOKEN}{percent}")), + ]; + let mut tokens = vec![layer, upper]; + tokens.extend(settings.into_iter().flatten()); + tokens } /// Reads the layout of a layered root from the boot tokens of a kernel command line, such as @@ -194,13 +277,14 @@ impl RootLayout { /// The command line splits at spaces, tabs, and newlines, without quoting, as the guest's init /// splits it, with [`cmdline::init_tokens`], and its tokens may come in any order. Each boot /// token must take one of the forms of the [module](self), with a value and at most once. The - /// image needs one upper, and each upper the image. A RAM upper also refuses a scratch block - /// device: the kernel adds a microVM's virtio-mmio devices from the - /// `virtio_mmio.device=SIZE@BASE:IRQ` parameters that the VMM appends, and none may sit at - /// [`SCRATCH_MMIO_BASE`]. The parser reads those parameters as the kernel does, with - /// [`cmdline::virtio_mmio_devices`], since the kernel splits its command line at more - /// whitespace than init, honors double quotes, and stops at `--`. Other tokens are ignored, as - /// are the devices that the layout uses, which the guest resolves itself. + /// image needs one upper, each upper and setting the image, and each setting the upper or the + /// format that it sets. A RAM upper also refuses a scratch block device: the kernel adds a + /// microVM's virtio-mmio devices from the `virtio_mmio.device=SIZE@BASE:IRQ` parameters that + /// the VMM appends, and none may sit at [`SCRATCH_MMIO_BASE`]. The parser reads those + /// parameters as the kernel does, with [`cmdline::virtio_mmio_devices`], since the kernel + /// splits its command line at more whitespace than init, honors double quotes, and stops at + /// `--`. Other tokens are ignored, as are the devices that the layout uses, which the guest + /// resolves itself. /// /// Fails with a [`ProtocolError`] that names the first token that breaks these rules, or the /// tokens that conflict. @@ -208,8 +292,14 @@ impl RootLayout { let mut image = None; let mut scratch = false; let mut ram = false; + let mut overlay = None; + let mut journal = None; + let mut gpt_first_partition_bound = None; + let mut max_memory_percent = None; + // The parameters that the command line names, in its order. + let mut seen = Vec::new(); for Token { name, value } in cmdline::init_tokens(command_line) { - let Some(parameter) = [LAYER, SCRATCH, OVERLAY_UPPER] + let Some(parameter) = PARAMETERS .into_iter() .find(|parameter| parameter.as_bytes() == name) else { @@ -220,16 +310,12 @@ impl RootLayout { "the {parameter} kernel parameter has no value" ))); }; - let seen = match parameter { - LAYER => image.is_some(), - SCRATCH => scratch, - _ => ram, - }; - if seen { + if seen.contains(¶meter) { return Err(violation(format!( "the {parameter} kernel parameter is repeated" ))); } + seen.push(parameter); match parameter { LAYER => image = Some(parse_image(value)?), SCRATCH => { @@ -242,7 +328,7 @@ impl RootLayout { } scratch = true; } - _ => { + OVERLAY_UPPER => { if value != RAM_UPPER.as_bytes() { return Err(violation(format!( "the {OVERLAY_UPPER} kernel parameter must be {RAM_UPPER}, not '{}'", @@ -251,6 +337,14 @@ impl RootLayout { } ram = true; } + OVERLAY_MODE => { + overlay = Some(named(parameter, value, OVERLAY_MODES, overlay_name)?) + } + SCRATCH_JOURNAL => journal = Some(named(parameter, value, JOURNALS, journal_name)?), + FIRST_PARTITION_BOUND => { + gpt_first_partition_bound = Some(number(parameter, value, 1..=u64::MAX)?); + } + _ => max_memory_percent = Some(number(parameter, value, RAM_UPPER_CAPS)?), } } let upper = match (scratch, ram) { @@ -270,25 +364,105 @@ impl RootLayout { (false, true) => Some(RootUpper::Ram), (false, false) => None, }; - match (image, upper) { - (Some(image), Some(upper)) => Ok(Some(Self { image, upper })), - (None, None) => Ok(None), - (Some(_), None) => Err(violation(format!( - "the {LAYER} kernel parameter needs an upper, {SCRATCH} or {OVERLAY_UPPER}" - ))), - (None, Some(upper)) => { - let parameter = match upper { - RootUpper::Scratch => SCRATCH, - RootUpper::Ram => OVERLAY_UPPER, - }; - Err(violation(format!( + // Without an image, the parameters that the command line names are those of an upper or + // of settings. + let Some(image) = image else { + return match seen.first() { + Some(parameter) => Err(violation(format!( "the {parameter} kernel parameter needs an image, which {LAYER} names" - ))) - } + ))), + None => Ok(None), + }; + }; + let Some(upper) = upper else { + return Err(violation(format!( + "the {LAYER} kernel parameter needs an upper, {SCRATCH} or {OVERLAY_UPPER}" + ))); + }; + let needs = if journal.is_some() && upper != RootUpper::Scratch { + Some((SCRATCH_JOURNAL, "a scratch upper, nvx_scratch")) + } else if max_memory_percent.is_some() && upper != RootUpper::Ram { + Some((RAM_UPPER_PERCENT, "a RAM upper, nvx_overlay_upper")) + } else if gpt_first_partition_bound.is_some() && image.format != ImageFormat::Gpt { + Some((FIRST_PARTITION_BOUND, "a GPT image")) + } else { + None + }; + if let Some((parameter, needs)) = needs { + return Err(violation(format!( + "the {parameter} kernel parameter needs {needs}" + ))); } + Ok(Some(Self { + image, + upper, + overlay, + journal, + gpt_first_partition_bound, + max_memory_percent, + })) + } +} + +/// Returns the name of `mode` in the overlay mode's token. +const fn overlay_name(mode: OverlayMode) -> &'static str { + match mode { + OverlayMode::TrustedLowers => "trusted-lowers", + OverlayMode::UntrustedLowers => "untrusted-lowers", } } +/// Returns the name of `journal` in the journal policy's token. +const fn journal_name(journal: Journal) -> &'static str { + match journal { + Journal::Allowed => "allowed", + Journal::Forbidden => "forbidden", + } +} + +/// Parses the value of the token of `parameter`, a setting of which `name` names each of +/// `settings`. +fn named( + parameter: &str, + value: &[u8], + settings: [T; 2], + name: fn(T) -> &'static str, +) -> Result { + let [first, second] = settings.map(name); + settings + .into_iter() + .find(|&setting| name(setting).as_bytes() == value) + .ok_or_else(|| { + violation(format!( + "the {parameter} kernel parameter must be {first} or {second}, not '{}'", + value.escape_ascii() + )) + }) +} + +/// Parses the value of the token of `parameter`, a decimal number in `range`, without a sign or +/// leading zeros. +fn number(parameter: &str, value: &[u8], range: RangeInclusive) -> Result +where + T: std::str::FromStr + PartialOrd + fmt::Display, +{ + // `str::parse` takes a leading `+` too. + std::str::from_utf8(value) + .ok() + .filter(|text| !text.starts_with(['0', '+'])) + .and_then(|text| text.parse().ok()) + .filter(|number| range.contains(number)) + .ok_or_else(|| { + violation(format!( + "the {parameter} kernel parameter must be a decimal number from {} to {}, \ + without a sign or leading zeros, not '{}'", + range.start(), + range.end(), + value.escape_ascii() + )) + }) +} + /// Parses the value of the image's token, `distro,0xd0003000,FORMAT[:IDENTITY]`. fn parse_image(value: &[u8]) -> Result { let fields: Vec<&[u8]> = value.split(|&byte| byte == b',').collect(); diff --git a/nvx-rs/src/protocol/root/tests.rs b/nvx-rs/src/protocol/root/tests.rs index 2af9335b8..241424766 100644 --- a/nvx-rs/src/protocol/root/tests.rs +++ b/nvx-rs/src/protocol/root/tests.rs @@ -14,23 +14,49 @@ const EROFS_UUID: &str = "0123abcd-4567-89ef-0123-456789abcdef"; /// The disk GUID of the tests' GPT images. const GPT_GUID: &str = "fedcba98-7654-3210-fedc-ba9876543210"; +/// The keys of a layout of the fixture. +const LAYOUT_KEYS: [&str; 7] = [ + "format", + "identity", + "upper", + "overlay", + "journal", + "gpt_first_partition_bound", + "max_memory_percent", +]; + fn uuid(text: &str) -> Uuid { Uuid::parse(text).unwrap() } +/// Returns the layout of `image` over `upper`, which names no setting. fn layout(image: RootImage, upper: RootUpper) -> RootLayout { - RootLayout { image, upper } + RootLayout { + image, + upper, + overlay: None, + journal: None, + gpt_first_partition_bound: None, + max_memory_percent: None, + } } fn parse(command_line: &str) -> Result, String> { RootLayout::parse(command_line.as_bytes()).map_err(ProtocolError::into_message) } -/// Returns the layout of a case of the fixture, or `None` for its `null`. +/// Returns the layout of a case of the fixture, or `None` for its `null`. A layout that leaves a +/// setting out names none. fn recorded_layout(value: &Value) -> Option { if value.is_null() { return None; } + for key in value.as_object().unwrap().keys() { + assert!( + LAYOUT_KEYS.contains(&key.as_str()), + "the fixture names the unknown {key}" + ); + } let format = ImageFormat::from_name(value["format"].as_str().unwrap()).unwrap(); let identity = value["identity"].as_str().map(uuid); let upper = match value["upper"].as_str().unwrap() { @@ -38,7 +64,25 @@ fn recorded_layout(value: &Value) -> Option { "ram" => RootUpper::Ram, other => panic!("the fixture names the unknown upper {other}"), }; - Some(layout(RootImage { format, identity }, upper)) + let overlay = value["overlay"].as_str().map(|mode| match mode { + "trusted-lowers" => OverlayMode::TrustedLowers, + "untrusted-lowers" => OverlayMode::UntrustedLowers, + other => panic!("the fixture names the unknown overlay mode {other}"), + }); + let journal = value["journal"].as_str().map(|journal| match journal { + "allowed" => Journal::Allowed, + "forbidden" => Journal::Forbidden, + other => panic!("the fixture names the unknown journal policy {other}"), + }); + Some(RootLayout { + overlay, + journal, + gpt_first_partition_bound: value["gpt_first_partition_bound"].as_u64(), + max_memory_percent: value["max_memory_percent"] + .as_u64() + .map(|percent| u8::try_from(percent).unwrap()), + ..layout(RootImage { format, identity }, upper) + }) } #[test] @@ -48,9 +92,26 @@ fn wire_values_keep_their_literals() { assert_eq!(LAYER_TOKEN, "nvx_layer="); assert_eq!(SCRATCH_TOKEN, "nvx_scratch="); assert_eq!(RAM_UPPER_TOKEN, "nvx_overlay_upper=ramfs"); + assert_eq!(OVERLAY_MODE_TOKEN, "nvx_overlay_mode="); + assert_eq!(SCRATCH_JOURNAL_TOKEN, "nvx_scratch_journal="); + assert_eq!(FIRST_PARTITION_BOUND_TOKEN, "nvx_first_partition_bound="); + assert_eq!(RAM_UPPER_PERCENT_TOKEN, "nvx_ram_upper_percent="); assert_eq!(format!("{LAYER}="), LAYER_TOKEN); assert_eq!(format!("{SCRATCH}="), SCRATCH_TOKEN); assert_eq!(format!("{OVERLAY_UPPER}={RAM_UPPER}"), RAM_UPPER_TOKEN); + assert_eq!(format!("{OVERLAY_MODE}="), OVERLAY_MODE_TOKEN); + assert_eq!(format!("{SCRATCH_JOURNAL}="), SCRATCH_JOURNAL_TOKEN); + assert_eq!( + format!("{FIRST_PARTITION_BOUND}="), + FIRST_PARTITION_BOUND_TOKEN + ); + assert_eq!(format!("{RAM_UPPER_PERCENT}="), RAM_UPPER_PERCENT_TOKEN); + assert_eq!( + OVERLAY_MODES.map(overlay_name), + ["trusted-lowers", "untrusted-lowers"] + ); + assert_eq!(JOURNALS.map(journal_name), ["allowed", "forbidden"]); + assert_eq!(RAM_UPPER_CAPS, 1..=90); assert_eq!(ImageFormat::Erofs.as_str(), "erofs"); assert_eq!(ImageFormat::Gpt.to_string(), "gpt"); assert_eq!(RootUpper::Scratch.as_str(), "scratch"); @@ -110,17 +171,89 @@ fn every_layout_renders_its_tokens_and_reads_them_back() { ram, ), ]; - for (expected, layer, upper) in cases { - assert_eq!(expected.tokens(), [layer.clone(), upper.to_owned()]); - // The guest reads the tokens in either order, among others. - for command_line in [ - format!("{layer} {upper}"), - format!("{upper} {layer}"), - format!("quiet nvx_sandbox=1 {layer}\t{upper}\nnvx_entrypoint=/bin/sh"), - ] { - assert_eq!(parse(&command_line), Ok(Some(expected)), "{command_line}"); + // Each setting that a layout may name, or none, with its token: the overlay's mode, a + // scratch's journal policy, a RAM upper's cap, and a GPT disk's bound. + let overlays = [ + (None, None), + ( + Some(OverlayMode::TrustedLowers), + Some("nvx_overlay_mode=trusted-lowers"), + ), + ( + Some(OverlayMode::UntrustedLowers), + Some("nvx_overlay_mode=untrusted-lowers"), + ), + ]; + let journals = [ + (None, None), + (Some(Journal::Allowed), Some("nvx_scratch_journal=allowed")), + ( + Some(Journal::Forbidden), + Some("nvx_scratch_journal=forbidden"), + ), + ]; + let caps = [ + (None, None), + (Some(1), Some("nvx_ram_upper_percent=1")), + (Some(90), Some("nvx_ram_upper_percent=90")), + ]; + let bounds = [ + (None, None), + (Some(1), Some("nvx_first_partition_bound=1")), + ( + Some(u64::MAX), + Some("nvx_first_partition_bound=18446744073709551615"), + ), + ]; + let mut layouts = 0; + for (bare, layer, upper) in cases { + let (journals, caps) = match bare.upper { + RootUpper::Scratch => (&journals[..], &[(None, None)][..]), + RootUpper::Ram => (&[(None, None)][..], &caps[..]), + }; + let bounds = match bare.image.format { + ImageFormat::Erofs => &[(None, None)][..], + ImageFormat::Gpt => &bounds[..], + }; + for &(overlay, overlay_token) in &overlays { + for &(journal, journal_token) in journals { + for &(max_memory_percent, cap_token) in caps { + for &(gpt_first_partition_bound, bound_token) in bounds { + let expected = RootLayout { + overlay, + journal, + gpt_first_partition_bound, + max_memory_percent, + ..bare + }; + let mut tokens = vec![layer.as_str(), upper]; + tokens.extend( + [overlay_token, journal_token, bound_token, cap_token] + .into_iter() + .flatten(), + ); + assert_eq!(expected.tokens(), tokens); + // The guest reads the tokens in any order, among others. + let reversed: Vec<&str> = tokens.iter().rev().copied().collect(); + for command_line in [ + tokens.join(" "), + reversed.join(" "), + format!( + "quiet nvx_sandbox=1 {}\nnvx_entrypoint=/bin/sh", + tokens.join("\t") + ), + ] { + assert_eq!(parse(&command_line), Ok(Some(expected)), "{command_line}"); + } + layouts += 1; + } + } + } } } + // Each of the 4 EROFS cases takes 3 overlay modes by 3 settings of its upper, and each of the + // 4 GPT cases 3 bounds more. + assert_eq!(layouts, 4 * 3 * 3 + 4 * 3 * 3 * 3); } #[test] @@ -166,10 +299,11 @@ fn every_reader_parses_the_recorded_command_lines_alike() { } } } - // The corpus holds every combination of format, identity, and upper. + // The corpus holds every combination of format, identity, and upper, and every combination of + // format, upper, and overlay mode with the settings that the format and the upper take. for format in ["erofs", "gpt"] { - for identity in [true, false] { - for upper in ["scratch", "ram"] { + for upper in ["scratch", "ram"] { + for identity in [true, false] { assert!( cases.iter().any(|case| { let layout = &case["layout"]; @@ -180,8 +314,34 @@ fn every_reader_parses_the_recorded_command_lines_alike() { "the fixture lacks {format} with identity {identity} over {upper}" ); } + let setting = match upper { + "scratch" => "journal", + _ => "max_memory_percent", + }; + for overlay in ["trusted-lowers", "untrusted-lowers"] { + assert!( + cases.iter().any(|case| { + let layout = &case["layout"]; + layout["format"] == format + && layout["upper"] == upper + && layout["overlay"] == overlay + && !layout[setting].is_null() + && (format == "erofs" || !layout["gpt_first_partition_bound"].is_null()) + }), + "the fixture lacks {format} over {upper} for {overlay} with its settings" + ); + } } } + // And each journal policy. + for journal in ["allowed", "forbidden"] { + assert!( + cases + .iter() + .any(|case| case["layout"]["journal"] == journal), + "the fixture lacks the journal policy {journal}" + ); + } } #[test] @@ -199,6 +359,24 @@ fn parsing_reports_bytes_that_are_not_text() { .message(), "the nvx_layer kernel parameter names the unsupported image format '\\xff'" ); + // So do the values of settings. + for (command_line, message) in [ + ( + b"nvx_overlay_mode=\xff".as_slice(), + "the nvx_overlay_mode kernel parameter must be trusted-lowers or untrusted-lowers, not \ + '\\xff'", + ), + ( + b"nvx_ram_upper_percent=9\xff".as_slice(), + "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, \ + without a sign or leading zeros, not '9\\xff'", + ), + ] { + assert_eq!( + RootLayout::parse(command_line).unwrap_err().message(), + message + ); + } // Other tokens may hold any bytes. assert_eq!( RootLayout::parse( @@ -227,12 +405,31 @@ fn contracted_upper(upper: RootUpper) -> &'static str { } } +/// Returns the token of `mode`, as the boot contract pins it. A new mode stops this match from +/// compiling until the boot contract names it. +fn contracted_overlay(mode: OverlayMode) -> &'static str { + match mode { + OverlayMode::TrustedLowers => "nvx_overlay_mode=trusted-lowers", + OverlayMode::UntrustedLowers => "nvx_overlay_mode=untrusted-lowers", + } +} + +/// Returns the token of `journal`, as the boot contract pins it. A new policy stops this match +/// from compiling until the boot contract names it. +fn contracted_journal(journal: Journal) -> &'static str { + match journal { + Journal::Allowed => "nvx_scratch_journal=allowed", + Journal::Forbidden => "nvx_scratch_journal=forbidden", + } +} + #[test] fn the_boot_contract_version_names_these_tokens() { // Runtime identities record the boot contract version, and a build stops, rather than // drives, a VM that a build of another version started. A change to these tokens, a new image // format or upper, or another way for the guest to assemble the root from them changes the // boot contract, and so its version, before the pins here change with it; see `contract`. + // The tokens of the root's settings joined version 5 while nothing deployed rendered them. assert_eq!( ( contract::BOOT_CONTRACT_VERSION, @@ -257,4 +454,32 @@ fn the_boot_contract_version_names_these_tokens() { } } } + // Each setting that a layout names adds its token after the upper's, in this order. + for overlay in OVERLAY_MODES { + for journal in JOURNALS { + let scratch = RootLayout { + overlay: Some(overlay), + journal: Some(journal), + gpt_first_partition_bound: Some(4096), + ..layout(RootImage::gpt(None), RootUpper::Scratch) + }; + assert_eq!( + scratch.tokens()[2..], + [ + contracted_overlay(overlay), + contracted_journal(journal), + "nvx_first_partition_bound=4096", + ] + ); + } + let ram = RootLayout { + overlay: Some(overlay), + max_memory_percent: Some(90), + ..layout(RootImage::erofs(None), RootUpper::Ram) + }; + assert_eq!( + ram.tokens()[2..], + [contracted_overlay(overlay), "nvx_ram_upper_percent=90"] + ); + } } diff --git a/nvx-rs/tests/fixtures/root-tokens.json b/nvx-rs/tests/fixtures/root-tokens.json index 1f3135d48..229aa07b2 100644 --- a/nvx-rs/tests/fixtures/root-tokens.json +++ b/nvx-rs/tests/fixtures/root-tokens.json @@ -1,5 +1,5 @@ { - "description": "Kernel command lines and the layout of a layered root that nvx::protocol::root parses from each, null for none, or the error with which it refuses the command line. The tests of nvx-rs replay them, and scripts/test_nvx_tools.py replays them through the shell init, guest/common/nvx-init-agent, which reads the same tokens: it accepts what the parser accepts of an EROFS image over either upper, and refuses everything else, with shell_error in its message where a case names one. It reads the tokens of a RAM upper beside a scratch block device too, and refuses that device when it finds it in /proc/iomem, where the kernel lists the virtio-mmio devices that it added.", + "description": "Kernel command lines and the layout of a layered root that nvx::protocol::root parses from each, null for none, or the error with which it refuses the command line. The tests of nvx-rs replay them, and scripts/test_nvx_tools.py replays them through the shell init, guest/common/nvx-init-agent, which reads the same tokens: it accepts what the parser accepts of an EROFS image over either upper, for trusted lowers and without a forbidden journal, and refuses everything else, with shell_error in its message where a case names one. It reads the tokens of a RAM upper beside a scratch block device too, and refuses that device when it finds it in /proc/iomem, where the kernel lists the virtio-mmio devices that it added.", "cases": [ {"name": "an EROFS image with its UUID over scratch", "command_line": "nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef nvx_scratch=0xd0006000,ext4", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "scratch"}}, {"name": "an EROFS image without an identity over scratch", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "layout": {"format": "erofs", "identity": null, "upper": "scratch"}}, @@ -79,6 +79,45 @@ {"name": "a RAM upper beside a scratch device token with text after its IRQ", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs virtio_mmio.device=0x1000@0xd0006000:22:", "layout": {"format": "erofs", "identity": null, "upper": "ram"}}, {"name": "a RAM upper beside a scratch device token in a quoted value", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs x=\"y virtio_mmio.device=0x1000@0xd0006000:22\"", "layout": {"format": "erofs", "identity": null, "upper": "ram"}}, {"name": "a RAM upper beside a scratch device token after --", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs -- virtio_mmio.device=0x1000@0xd0006000:22", "layout": {"format": "erofs", "identity": null, "upper": "ram"}}, - {"name": "a scratch upper beside its block device", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 virtio_mmio.device=0x1000@0xd0003000:19 virtio_mmio.device=0x1000@0xd0006000:22", "layout": {"format": "erofs", "identity": null, "upper": "scratch"}} + {"name": "a scratch upper beside its block device", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 virtio_mmio.device=0x1000@0xd0003000:19 virtio_mmio.device=0x1000@0xd0006000:22", "layout": {"format": "erofs", "identity": null, "upper": "scratch"}}, + {"name": "an EROFS image over scratch for trusted lowers, with a journal allowed", "command_line": "nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=trusted-lowers nvx_scratch_journal=allowed", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "scratch", "overlay": "trusted-lowers", "journal": "allowed"}}, + {"name": "an EROFS image over scratch for untrusted lowers, without a journal", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=untrusted-lowers nvx_scratch_journal=forbidden", "layout": {"format": "erofs", "identity": null, "upper": "scratch", "overlay": "untrusted-lowers", "journal": "forbidden"}, "shell_error": "untrusted lowers"}, + {"name": "an EROFS image in RAM for trusted lowers, with a cap", "command_line": "nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef nvx_overlay_upper=ramfs nvx_overlay_mode=trusted-lowers nvx_ram_upper_percent=25", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "ram", "overlay": "trusted-lowers", "max_memory_percent": 25}}, + {"name": "an EROFS image in RAM for untrusted lowers, with the largest cap, its settings first", "command_line": "nvx_ram_upper_percent=90 nvx_overlay_mode=untrusted-lowers nvx_overlay_upper=ramfs nvx_layer=distro,0xd0003000,erofs", "layout": {"format": "erofs", "identity": null, "upper": "ram", "overlay": "untrusted-lowers", "max_memory_percent": 90}, "shell_error": "untrusted lowers"}, + {"name": "a GPT image over scratch for trusted lowers, without a journal, with the smallest bound", "command_line": "nvx_layer=distro,0xd0003000,gpt:fedcba98-7654-3210-fedc-ba9876543210 nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=trusted-lowers nvx_scratch_journal=forbidden nvx_first_partition_bound=1", "layout": {"format": "gpt", "identity": "fedcba98-7654-3210-fedc-ba9876543210", "upper": "scratch", "overlay": "trusted-lowers", "journal": "forbidden", "gpt_first_partition_bound": 1}, "shell_error": "GPT image"}, + {"name": "a GPT image over scratch for untrusted lowers, with a journal allowed and the largest bound", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=untrusted-lowers nvx_scratch_journal=allowed nvx_first_partition_bound=18446744073709551615", "layout": {"format": "gpt", "identity": null, "upper": "scratch", "overlay": "untrusted-lowers", "journal": "allowed", "gpt_first_partition_bound": 18446744073709551615}, "shell_error": "GPT image"}, + {"name": "a GPT image in RAM for trusted lowers, with the smallest cap and a bound", "command_line": "nvx_layer=distro,0xd0003000,gpt:fedcba98-7654-3210-fedc-ba9876543210 nvx_overlay_upper=ramfs nvx_overlay_mode=trusted-lowers nvx_ram_upper_percent=1 nvx_first_partition_bound=4096", "layout": {"format": "gpt", "identity": "fedcba98-7654-3210-fedc-ba9876543210", "upper": "ram", "overlay": "trusted-lowers", "max_memory_percent": 1, "gpt_first_partition_bound": 4096}, "shell_error": "GPT image"}, + {"name": "a GPT image in RAM for untrusted lowers, with a cap and a bound", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_overlay_upper=ramfs nvx_overlay_mode=untrusted-lowers nvx_ram_upper_percent=50 nvx_first_partition_bound=65536", "layout": {"format": "gpt", "identity": null, "upper": "ram", "overlay": "untrusted-lowers", "max_memory_percent": 50, "gpt_first_partition_bound": 65536}, "shell_error": "GPT image"}, + {"name": "an EROFS image over scratch without a journal", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_scratch_journal=forbidden", "layout": {"format": "erofs", "identity": null, "upper": "scratch", "journal": "forbidden"}, "shell_error": "forbids the scratch a journal"}, + {"name": "the settings among the tokens of a managed sandbox", "command_line": "quiet nvx_ram_upper_percent=90 nvx_sandbox=1 nvx_overlay_mode=trusted-lowers nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef nvx_overlay_upper=ramfs nvx_entrypoint=/bin/sh", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "ram", "overlay": "trusted-lowers", "max_memory_percent": 90}}, + {"name": "parameters whose names only begin or end like the settings' tokens", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_modes=x nvx_overlay_mode_x=1 nvx-overlay-mode=untrusted-lowers nvx_scratch_journals=forbidden nvx_first_partition_bounds=0 nvx_ram_upper=0 nvx_ram_upper_percents=0 xnvx_ram_upper_percent=0", "layout": {"format": "erofs", "identity": null, "upper": "scratch"}}, + {"name": "a capped RAM upper with a scratch block device", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=40 virtio_mmio.device=0x1000@0xd0006000:22", "error": "a RAM upper takes no scratch block device, but the command line adds a virtio-mmio device at 0xd0006000"}, + {"name": "an overlay mode without a value", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_mode", "error": "the nvx_overlay_mode kernel parameter has no value"}, + {"name": "a bound without a value, before its image", "command_line": "nvx_first_partition_bound nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter has no value"}, + {"name": "two journal policies", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_scratch_journal=allowed nvx_scratch_journal=allowed", "error": "the nvx_scratch_journal kernel parameter is repeated"}, + {"name": "two caps", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=50 nvx_ram_upper_percent=60", "error": "the nvx_ram_upper_percent kernel parameter is repeated"}, + {"name": "an unknown overlay mode", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=trusted", "error": "the nvx_overlay_mode kernel parameter must be trusted-lowers or untrusted-lowers, not 'trusted'"}, + {"name": "an overlay mode in capitals", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=TRUSTED-LOWERS", "error": "the nvx_overlay_mode kernel parameter must be trusted-lowers or untrusted-lowers, not 'TRUSTED-LOWERS'"}, + {"name": "an overlay mode with an empty value", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=", "error": "the nvx_overlay_mode kernel parameter must be trusted-lowers or untrusted-lowers, not ''"}, + {"name": "an unknown journal policy", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_scratch_journal=none", "error": "the nvx_scratch_journal kernel parameter must be allowed or forbidden, not 'none'"}, + {"name": "a cap of 0, which the kernel reads as none", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=0", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '0'"}, + {"name": "a cap past 90 percent", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=91", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '91'"}, + {"name": "a cap past 8 bits", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=256", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '256'"}, + {"name": "a cap with a leading zero", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=050", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '050'"}, + {"name": "a cap with a sign", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=+50", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '+50'"}, + {"name": "a cap with a percent sign", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=50%", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '50%'"}, + {"name": "a cap with an empty value", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not ''"}, + {"name": "a bound of 0, before its image", "command_line": "nvx_first_partition_bound=0 nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '0'"}, + {"name": "a hexadecimal bound, before its image", "command_line": "nvx_first_partition_bound=0x1000 nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '0x1000'"}, + {"name": "a bound past 64 bits", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4 nvx_first_partition_bound=18446744073709551616", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'"}, + {"name": "a bound past 64 bits before an EROFS image", "command_line": "nvx_first_partition_bound=18446744073709551616 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'", "shell_error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'"}, + {"name": "a 21 digit bound before an EROFS image", "command_line": "nvx_first_partition_bound=184467440737095516150 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '184467440737095516150'", "shell_error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '184467440737095516150'"}, + {"name": "the largest bound before an EROFS image", "command_line": "nvx_first_partition_bound=18446744073709551615 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter needs a GPT image", "shell_error": "the nvx_first_partition_bound kernel parameter needs a GPT image"}, + {"name": "a 20 digit bound below the largest before an EROFS image", "command_line": "nvx_first_partition_bound=17999999999999999999 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter needs a GPT image", "shell_error": "the nvx_first_partition_bound kernel parameter needs a GPT image"}, + {"name": "a journal policy in RAM", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_scratch_journal=allowed", "error": "the nvx_scratch_journal kernel parameter needs a scratch upper, nvx_scratch"}, + {"name": "a cap over scratch", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_ram_upper_percent=50", "error": "the nvx_ram_upper_percent kernel parameter needs a RAM upper, nvx_overlay_upper"}, + {"name": "a bound of an EROFS image", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_first_partition_bound=4096", "error": "the nvx_first_partition_bound kernel parameter needs a GPT image"}, + {"name": "settings without a root", "command_line": "quiet nvx_overlay_mode=trusted-lowers nvx_scratch_journal=allowed", "error": "the nvx_overlay_mode kernel parameter needs an image, which nvx_layer names"}, + {"name": "a cap before a RAM upper without an image", "command_line": "nvx_ram_upper_percent=50 nvx_overlay_upper=ramfs", "error": "the nvx_ram_upper_percent kernel parameter needs an image, which nvx_layer names"} ] } diff --git a/scripts/test_nvx_tools.py b/scripts/test_nvx_tools.py index 402f0c285..c653e4a84 100644 --- a/scripts/test_nvx_tools.py +++ b/scripts/test_nvx_tools.py @@ -19407,7 +19407,8 @@ def setUp(self): def _read(self, command_line: str) -> subprocess.CompletedProcess[str]: """Runs the agent's reader of the root's boot tokens on `command_line`, - and prints the UUID that it expects of the distro layer and the upper.""" + and prints the UUID that it expects of the distro layer, the upper, and + the RAM upper's cap.""" functions = "".join( _shell_function(self.source, name) for name in ("is_canonical_uuid", "read_layer_token", "read_root_tokens") @@ -19424,7 +19425,7 @@ def _read(self, command_line: str) -> subprocess.CompletedProcess[str]: 'fatal() { echo "FATAL: $*" >&2; exit 125; }\n' f"{functions}" "read_root_tokens\n" - 'echo "layer_uuid=$layer_uuid upper=$upper"\n' + 'echo "layer_uuid=$layer_uuid upper=$upper ram_percent=$ram_percent"\n' ), text=True, capture_output=True, @@ -19434,7 +19435,7 @@ def _read(self, command_line: str) -> subprocess.CompletedProcess[str]: def test_agent_reads_the_boot_tokens_as_nvx_rs_reads_them(self): cases = json.loads(self.FIXTURE.read_text(encoding="utf-8"))["cases"] accepted: set[str] = set() - devices = 0 + capped = devices = 0 for case in cases: with self.subTest(case=case["name"]): result = self._read(case["command_line"]) @@ -19442,23 +19443,35 @@ def test_agent_reads_the_boot_tokens_as_nvx_rs_reads_them(self): if case.get("error") == self.SCRATCH_DEVICE_ERROR: self.assertNotIn("shell_error", case) self.assertEqual(result.returncode, 0, result.stderr) - self.assertRegex(result.stdout, r"\Alayer_uuid=\S* upper=ram\n\Z") + self.assertRegex( + result.stdout, r"\Alayer_uuid=\S* upper=ram ram_percent=\d*\n\Z" + ) devices += 1 continue - if layout is not None and layout["format"] == "erofs": + # The shell init assembles an EROFS image for trusted lowers, and + # cannot check that a scratch has no journal. + if ( + layout is not None + and layout["format"] == "erofs" + and layout.get("overlay") != "untrusted-lowers" + and layout.get("journal") != "forbidden" + ): self.assertNotIn("shell_error", case) self.assertEqual(result.returncode, 0, result.stderr) identity = layout["identity"] or "" + cap = layout.get("max_memory_percent", "") self.assertEqual( result.stdout, - f"layer_uuid={identity} upper={layout['upper']}\n", + f"layer_uuid={identity} upper={layout['upper']} ram_percent={cap}\n", ) accepted.add(layout["upper"]) + capped += cap != "" continue self.assertEqual(result.returncode, 125, result.stdout) self.assertRegex(result.stderr, r"\AFATAL: [^\n]+\n\Z") self.assertIn(case.get("shell_error", ""), result.stderr) self.assertEqual(accepted, {"scratch", "ram"}) + self.assertGreater(capped, 0) self.assertGreater(devices, 0) def test_agent_explains_the_roots_that_it_cannot_assemble(self): @@ -19473,6 +19486,83 @@ def test_agent_explains_the_roots_that_it_cannot_assemble(self): f"nvx_layer=distro,0xd0003000,gpt:{self.UUID} names a GPT image, " "which this init cannot assemble: it assembles only an EROFS image", ), + ( + "nvx_overlay_mode=untrusted-lowers nvx_layer=distro,0xd0003000,erofs " + "nvx_overlay_upper=ramfs", + "nvx_overlay_mode=untrusted-lowers names untrusted lowers, which " + "this init cannot assemble: it mounts the overlay only for trusted " + "lowers, with metacopy=on,xino=on", + ), + ( + "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 " + "nvx_scratch_journal=forbidden", + "nvx_scratch_journal=forbidden forbids the scratch a journal, which " + "this init cannot check: it reads nothing of the scratch's superblock", + ), + ): + with self.subTest(command_line=command_line): + result = self._read(command_line) + self.assertEqual(result.returncode, 125, result.stdout) + self.assertEqual(result.stderr, f"FATAL: {message}\n") + + def test_agent_refuses_the_settings_with_the_messages_of_nvx_rs(self): + scratch = "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4" + ram = "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs" + number = "a decimal number from 1 to {}, without a sign or leading zeros" + percent, bound = number.format(90), number.format(2**64 - 1) + for command_line, message in ( + ( + f"{scratch} nvx_scratch_journal", + "the nvx_scratch_journal kernel parameter has no value", + ), + ( + f"{ram} nvx_ram_upper_percent=5 nvx_ram_upper_percent=5", + "the nvx_ram_upper_percent kernel parameter is repeated", + ), + ( + f"{scratch} nvx_overlay_mode=trusted", + "the nvx_overlay_mode kernel parameter must be trusted-lowers or " + "untrusted-lowers, not 'trusted'", + ), + ( + f"{scratch} nvx_scratch_journal=", + "the nvx_scratch_journal kernel parameter must be allowed or " + "forbidden, not ''", + ), + ( + f"{ram} nvx_ram_upper_percent=91", + f"the nvx_ram_upper_percent kernel parameter must be {percent}, not '91'", + ), + ( + f"{ram} nvx_ram_upper_percent=09", + f"the nvx_ram_upper_percent kernel parameter must be {percent}, not '09'", + ), + ( + f"nvx_first_partition_bound=-1 {scratch}", + f"the nvx_first_partition_bound kernel parameter must be {bound}, " + "not '-1'", + ), + ( + f"{ram} nvx_scratch_journal=allowed", + "the nvx_scratch_journal kernel parameter needs a scratch upper, " + "nvx_scratch", + ), + ( + f"{scratch} nvx_ram_upper_percent=50", + "the nvx_ram_upper_percent kernel parameter needs a RAM upper, " + "nvx_overlay_upper", + ), + ( + f"{scratch} nvx_first_partition_bound=4096", + "the nvx_first_partition_bound kernel parameter needs a GPT image", + ), + # The parser's refusals come before those of what this init cannot + # assemble. + ( + f"{scratch} nvx_overlay_mode=untrusted-lowers nvx_scratch_journal=no", + "the nvx_scratch_journal kernel parameter must be allowed or " + "forbidden, not 'no'", + ), ): with self.subTest(command_line=command_line): result = self._read(command_line) @@ -19573,9 +19663,10 @@ def test_agent_checks_a_ram_upper_before_it_mounts_anything(self): def test_agent_mounts_the_upper_that_the_tokens_name(self): runtime = (self.root / "run").as_posix() log = self.root / "commands.log" - for upper, commands, point, name in ( + for upper, ram_percent, commands, point, name in ( ( "scratch", + "", [ f"mkdir -p {runtime}/scratch", "resolve 0xd0006000", @@ -19588,6 +19679,7 @@ def test_agent_mounts_the_upper_that_the_tokens_name(self): ), ( "ram", + "", [ f"mkdir -p {runtime}/tmpfs", f"mount -t tmpfs -o mode=0755,nosuid,nodev tmpfs {runtime}/tmpfs", @@ -19596,14 +19688,34 @@ def test_agent_mounts_the_upper_that_the_tokens_name(self): f"{runtime}/tmpfs", "the tmpfs upper", ), + ( + "ram", + "25", + [ + f"mkdir -p {runtime}/tmpfs", + "mount -t tmpfs -o mode=0755,nosuid,nodev,size=25% tmpfs " + f"{runtime}/tmpfs", + f"mkdir -p {runtime}/tmpfs/upper {runtime}/tmpfs/work", + ], + f"{runtime}/tmpfs", + "the tmpfs upper", + ), ): - with self.subTest(upper=upper): + with self.subTest(upper=upper, ram_percent=ram_percent): log.unlink(missing_ok=True) result = subprocess.run( - [self.shell, "-s", "--", runtime, log.as_posix(), upper], + [ + self.shell, + "-s", + "--", + runtime, + log.as_posix(), + upper, + ram_percent, + ], input=( "set -eu\n" - "runtime=$1\nlog=$2\nupper=$3\n" + "runtime=$1\nlog=$2\nupper=$3\nram_percent=$4\n" "scratch=$runtime/scratch\ntmpfs=$runtime/tmpfs\n" 'fatal() { echo "FATAL: $*" >&2; exit 125; }\n' 'resolve_block_device() { echo "resolve $*" >>"$log"; ' diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index d28a31e78..5a54b719e 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -91,9 +91,11 @@ what the guest did. ### Root configurations The boot tokens of `nvx::protocol::root` carry what NVX's boot contract -defines: the image's format, the identity that the guest expects of it, and -the upper. A root configuration, `root::RootConfig`, carries the rest that the -root needs, which only the adapter defines, once the host attaches. It is +defines: the image's format, the identity that the guest expects of it, the +upper, and the root's settings. The adapter's plan names none of the settings, +and the worker reads only a RAM upper's cap from them. A root configuration, +`root::RootConfig`, carries the rest that the root needs, which only the +adapter defines, once the host attaches. It is UTF-8 text: one `NAME=VALUE` line per field, each ended by a newline, in any order, and each at most once. Numbers are decimal, without a sign or leading zeros: diff --git a/tests/runtime-adapter/src/worker.rs b/tests/runtime-adapter/src/worker.rs index a0aa84bdd..dc166c88d 100644 --- a/tests/runtime-adapter/src/worker.rs +++ b/tests/runtime-adapter/src/worker.rs @@ -1236,11 +1236,11 @@ impl RootOperations for LibraryRoot { /// the EROFS image, with the identity that the boot tokens expect of it, or the GPT disk, with /// the disk GUID that they expect of it, whose first partition holds at most the /// configuration's bound, over the ext4 scratch, with the configuration's journal policy, or - /// over a tmpfs, in the configuration's overlay mode, with the configuration's runtime - /// directory, device sizes, and timeout. A bound, a policy, or a mode that the configuration - /// does not name is the guest's default: the first partition holds at most - /// [`DEFAULT_FIRST_PARTITION_BOUND`] bytes, a scratch may have a journal, and the overlay - /// trusts its lowers. + /// over a tmpfs, with the cap that the boot tokens name, if any, in the configuration's + /// overlay mode, with the configuration's runtime directory, device sizes, and timeout. A + /// bound, a policy, or a mode that the configuration does not name is the guest's default: the + /// first partition holds at most [`DEFAULT_FIRST_PARTITION_BOUND`] bytes, a scratch may have a + /// journal, and the overlay trusts its lowers. fn assemble(&mut self, config: &RootConfig, layout: &RootLayout) -> Result<(), String> { let image = match layout.image.format { ImageFormat::Erofs => Image::Erofs { @@ -1274,7 +1274,9 @@ impl RootOperations for LibraryRoot { Some(JournalPolicy::Forbidden) => Journal::Forbidden, }, }, - RootUpper::Ram => Upper::Tmpfs, + RootUpper::Ram => Upper::Tmpfs { + max_memory_percent: layout.max_memory_percent, + }, }; let overlay = match config.overlay_mode { None => root::OverlayMode::default(), diff --git a/tests/runtime-adapter/src/worker/activation/tests.rs b/tests/runtime-adapter/src/worker/activation/tests.rs index e2ac0c156..cbc439cb4 100644 --- a/tests/runtime-adapter/src/worker/activation/tests.rs +++ b/tests/runtime-adapter/src/worker/activation/tests.rs @@ -150,7 +150,9 @@ fn each_class_of_failure_ends_the_clone_with_its_own_status() { sectors: None, }, image: root::Image::Erofs { uuid: None }, - upper: root::Upper::Tmpfs, + upper: root::Upper::Tmpfs { + max_memory_percent: None, + }, overlay: root::OverlayMode::default(), timeout: Duration::ZERO, }; diff --git a/tests/runtime-adapter/tests/guest_session.rs b/tests/runtime-adapter/tests/guest_session.rs index 4980e9e67..61777bc98 100644 --- a/tests/runtime-adapter/tests/guest_session.rs +++ b/tests/runtime-adapter/tests/guest_session.rs @@ -1631,11 +1631,15 @@ impl Drop for Workload { } /// Returns a layout that the worker assembles: an EROFS image, with the identity that the guest -/// expects of it, over an ext4 scratch. +/// expects of it, over an ext4 scratch, with none of the root's settings. fn layout() -> RootLayout { RootLayout { image: RootImage::erofs(Uuid::parse("0a490905-41be-ed5f-984e-63eb7e5e20b1")), upper: RootUpper::Scratch, + overlay: None, + journal: None, + gpt_first_partition_bound: None, + max_memory_percent: None, } } @@ -1815,8 +1819,10 @@ fn the_worker_assembles_the_root_serves_it_and_tears_it_down_on_stop() { #[test] fn the_worker_assembles_an_image_over_ram_in_the_overlay_mode_of_its_configuration() { + // The boot tokens cap the RAM upper, and the worker hands the cap to the root in the layout. let ram = RootLayout { upper: RootUpper::Ram, + max_memory_percent: Some(25), ..layout() }; let config = RootConfig { @@ -1843,7 +1849,7 @@ fn the_worker_assembles_an_image_over_ram_in_the_overlay_mode_of_its_configurati fn the_worker_assembles_a_gpt_disk_with_the_first_partition_bound_of_its_configuration() { let gpt = RootLayout { image: RootImage::gpt(Uuid::parse("6c1f5e0a-2b7d-4c39-9e81-3a5d0f7b2c64")), - upper: RootUpper::Scratch, + ..layout() }; let config = RootConfig { first_partition_bound: Some(4096), diff --git a/tests/runtime-adapter/tests/layered_launch.rs b/tests/runtime-adapter/tests/layered_launch.rs index 2ac965a45..af6ed2c19 100644 --- a/tests/runtime-adapter/tests/layered_launch.rs +++ b/tests/runtime-adapter/tests/layered_launch.rs @@ -259,6 +259,10 @@ fn a_layered_launch_plans_its_blocks_and_boot_tokens_with_the_adapters_plan() { let layout = RootLayout { image: RootImage::erofs(Some(identity())), upper: RootUpper::Scratch, + overlay: None, + journal: None, + gpt_first_partition_bound: None, + max_memory_percent: None, }; assert_eq!(launch.plan.layout(), layout); // The adapter's plan holds none of the settings of NVX's sandbox profile. From c016e2c0e0f4c1c7998fe8b10e2f562726027861 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 17:03:01 -0800 Subject: [PATCH 2/3] guest: validate a bound before refusing its GPT image Read the root settings before the init refuses GPT images, so an invalid unsigned bound has the same numeric diagnostic in either token order. Keep GPT unsupported and pin the existing image-first overflow fixture's shell diagnostic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e354d65-494d-4fa9-ae9b-4f2e86c3c0f8 --- guest/common/nvx-init-agent | 15 +++++++++------ nvx-rs/tests/fixtures/root-tokens.json | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/guest/common/nvx-init-agent b/guest/common/nvx-init-agent index 15d6ea49d..164fabed5 100644 --- a/guest/common/nvx-init-agent +++ b/guest/common/nvx-init-agent @@ -178,11 +178,11 @@ is_canonical_uuid() { return 1 } -# Reads the value of the nvx_layer token for read_root_tokens: -# distro,0xd0003000,FORMAT[:IDENTITY], where this init accepts only the erofs -# format, with or without the filesystem UUID that the image must have. +# Reads the nvx_layer format and EROFS identity for read_root_tokens. +# This init assembles only EROFS; it refuses GPT after root-token validation. read_layer_token() { layer_image=${1#distro,0xd0003000,} + layer_format=${layer_image%%:*} case "$1" in distro,0xd0003000,*,*) ;; distro,0xd0003000,erofs) @@ -196,10 +196,9 @@ read_layer_token() { return 0 ;; distro,0xd0003000,gpt | distro,0xd0003000,gpt:*) - fatal "nvx_layer=$1 names a GPT image, which this init cannot assemble: it assembles only an EROFS image" + return 0 ;; distro,0xd0003000,*) - layer_format=${layer_image%%:*} # The form without a format names the UUID in its place. if [ -z "$layer_format" ] || is_canonical_uuid "$layer_format"; then fatal "the nvx_layer kernel parameter names no image format, erofs or gpt: '$1'" @@ -314,7 +313,11 @@ read_root_tokens() { fatal "the nvx_scratch_journal kernel parameter needs a scratch upper, nvx_scratch" [ -z "$ram_percent" ] || [ "$upper" = ram ] || fatal "the nvx_ram_upper_percent kernel parameter needs a RAM upper, nvx_overlay_upper" - case " $settings " in *" nvx_first_partition_bound "*) fatal "the nvx_first_partition_bound kernel parameter needs a GPT image" ;; esac + if [ "$layer_format" != gpt ]; then + case " $settings " in *" nvx_first_partition_bound "*) fatal "the nvx_first_partition_bound kernel parameter needs a GPT image" ;; esac + fi + [ "$layer_format" != gpt ] || + fatal "nvx_layer=distro,0xd0003000,$layer_image names a GPT image, which this init cannot assemble: it assembles only an EROFS image" [ "$overlay_mode" != untrusted-lowers ] || fatal "nvx_overlay_mode=untrusted-lowers names untrusted lowers, which this init cannot assemble: it mounts the overlay only for trusted lowers, with metacopy=on,xino=on" [ "$scratch_journal" != forbidden ] || diff --git a/nvx-rs/tests/fixtures/root-tokens.json b/nvx-rs/tests/fixtures/root-tokens.json index 229aa07b2..e7314963b 100644 --- a/nvx-rs/tests/fixtures/root-tokens.json +++ b/nvx-rs/tests/fixtures/root-tokens.json @@ -109,7 +109,7 @@ {"name": "a cap with an empty value", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not ''"}, {"name": "a bound of 0, before its image", "command_line": "nvx_first_partition_bound=0 nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '0'"}, {"name": "a hexadecimal bound, before its image", "command_line": "nvx_first_partition_bound=0x1000 nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '0x1000'"}, - {"name": "a bound past 64 bits", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4 nvx_first_partition_bound=18446744073709551616", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'"}, + {"name": "a bound past 64 bits", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4 nvx_first_partition_bound=18446744073709551616", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'", "shell_error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'"}, {"name": "a bound past 64 bits before an EROFS image", "command_line": "nvx_first_partition_bound=18446744073709551616 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'", "shell_error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'"}, {"name": "a 21 digit bound before an EROFS image", "command_line": "nvx_first_partition_bound=184467440737095516150 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '184467440737095516150'", "shell_error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '184467440737095516150'"}, {"name": "the largest bound before an EROFS image", "command_line": "nvx_first_partition_bound=18446744073709551615 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter needs a GPT image", "shell_error": "the nvx_first_partition_bound kernel parameter needs a GPT image"}, From ba9b85f8eb1fe65dd699ceaf076177ab9ed6c9ed Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 6 Nov 2026 20:35:22 -0800 Subject: [PATCH 3/3] Match missing-image diagnostics to root token order Track the first upper or setting token so a missing image reports the same parameter as the Rust reader. Pin both existing missing-image fixture diagnostics without changing their cases or accepting invalid roots. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e354d65-494d-4fa9-ae9b-4f2e86c3c0f8 --- guest/common/nvx-init-agent | 6 ++++++ nvx-rs/tests/fixtures/root-tokens.json | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/guest/common/nvx-init-agent b/guest/common/nvx-init-agent index 164fabed5..0aa4fa26a 100644 --- a/guest/common/nvx-init-agent +++ b/guest/common/nvx-init-agent @@ -229,6 +229,7 @@ read_root_tokens() { scratch_seen=false ram_seen=false layer_uuid= + first_root_parameter= # The names of the settings that the command line names, and the values of # three of them. settings= @@ -250,6 +251,7 @@ read_root_tokens() { read_layer_token "${token#*=}" ;; nvx_scratch=*) + [ -n "$first_root_parameter" ] || first_root_parameter=nvx_scratch [ "$scratch_seen" = false ] || fatal "the nvx_scratch kernel parameter is repeated" scratch_seen=true @@ -257,6 +259,7 @@ read_root_tokens() { fatal "the nvx_scratch kernel parameter must be 0xd0006000,ext4, not '${token#*=}'" ;; nvx_overlay_upper=*) + [ -n "$first_root_parameter" ] || first_root_parameter=nvx_overlay_upper [ "$ram_seen" = false ] || fatal "the nvx_overlay_upper kernel parameter is repeated" ram_seen=true @@ -265,6 +268,7 @@ read_root_tokens() { ;; nvx_overlay_mode=* | nvx_scratch_journal=* | nvx_first_partition_bound=* | nvx_ram_upper_percent=*) setting=${token%%=*} + [ -n "$first_root_parameter" ] || first_root_parameter=$setting case " $settings " in *" $setting "*) fatal "the $setting kernel parameter is repeated" ;; esac settings="$settings $setting" case "$token" in @@ -300,6 +304,8 @@ read_root_tokens() { set +f [ "$scratch_seen" = false ] || [ "$ram_seen" = false ] || fatal "a layered root has one upper, but the command line names both nvx_scratch and nvx_overlay_upper" + [ "$layer_seen" = true ] || [ -z "$first_root_parameter" ] || + fatal "the $first_root_parameter kernel parameter needs an image, which nvx_layer names" [ "$layer_seen" = true ] || fatal "the command line names no distro layer: this init needs nvx_layer=distro,0xd0003000,erofs[:UUID]" if [ "$scratch_seen" = true ]; then diff --git a/nvx-rs/tests/fixtures/root-tokens.json b/nvx-rs/tests/fixtures/root-tokens.json index e7314963b..9d3e47e90 100644 --- a/nvx-rs/tests/fixtures/root-tokens.json +++ b/nvx-rs/tests/fixtures/root-tokens.json @@ -117,7 +117,7 @@ {"name": "a journal policy in RAM", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_scratch_journal=allowed", "error": "the nvx_scratch_journal kernel parameter needs a scratch upper, nvx_scratch"}, {"name": "a cap over scratch", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_ram_upper_percent=50", "error": "the nvx_ram_upper_percent kernel parameter needs a RAM upper, nvx_overlay_upper"}, {"name": "a bound of an EROFS image", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_first_partition_bound=4096", "error": "the nvx_first_partition_bound kernel parameter needs a GPT image"}, - {"name": "settings without a root", "command_line": "quiet nvx_overlay_mode=trusted-lowers nvx_scratch_journal=allowed", "error": "the nvx_overlay_mode kernel parameter needs an image, which nvx_layer names"}, - {"name": "a cap before a RAM upper without an image", "command_line": "nvx_ram_upper_percent=50 nvx_overlay_upper=ramfs", "error": "the nvx_ram_upper_percent kernel parameter needs an image, which nvx_layer names"} + {"name": "settings without a root", "command_line": "quiet nvx_overlay_mode=trusted-lowers nvx_scratch_journal=allowed", "error": "the nvx_overlay_mode kernel parameter needs an image, which nvx_layer names", "shell_error": "the nvx_overlay_mode kernel parameter needs an image, which nvx_layer names"}, + {"name": "a cap before a RAM upper without an image", "command_line": "nvx_ram_upper_percent=50 nvx_overlay_upper=ramfs", "error": "the nvx_ram_upper_percent kernel parameter needs an image, which nvx_layer names", "shell_error": "the nvx_ram_upper_percent kernel parameter needs an image, which nvx_layer names"} ] }