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..0aa4fa26a 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'" @@ -212,24 +211,37 @@ 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= + first_root_parameter= + # 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=*) @@ -239,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 @@ -246,17 +259,53 @@ 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 [ "${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%%=*} + [ -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 + 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 [ "$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 @@ -266,6 +315,19 @@ 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" + 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 ] || + 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 +350,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 +372,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..9d3e47e90 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'", "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"}, + {"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", "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"} ] } 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.