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..164fabed5 100644 --- a/guest/common/nvx-init-agent +++ b/guest/common/nvx-init-agent @@ -178,11 +178,11 @@ is_canonical_uuid() { return 1 } -# Reads the value of the nvx_layer token for read_root_tokens: -# distro,0xd0003000,FORMAT[:IDENTITY], where this init accepts only the erofs -# format, with or without the filesystem UUID that the image must have. +# Reads the nvx_layer format and EROFS identity for read_root_tokens. +# This init assembles only EROFS; it refuses GPT after root-token validation. read_layer_token() { layer_image=${1#distro,0xd0003000,} + layer_format=${layer_image%%:*} case "$1" in distro,0xd0003000,*,*) ;; distro,0xd0003000,erofs) @@ -196,10 +196,9 @@ read_layer_token() { return 0 ;; distro,0xd0003000,gpt | distro,0xd0003000,gpt:*) - fatal "nvx_layer=$1 names a GPT image, which this init cannot assemble: it assembles only an EROFS image" + return 0 ;; distro,0xd0003000,*) - layer_format=${layer_image%%:*} # The form without a format names the UUID in its place. if [ -z "$layer_format" ] || is_canonical_uuid "$layer_format"; then fatal "the nvx_layer kernel parameter names no image format, erofs or gpt: '$1'" @@ -212,24 +211,36 @@ read_layer_token() { # Reads the boot tokens of the layered root, with the rules of the host's # renderer, nvx::protocol::root: nvx_layer names the image of the distro block -# device, and nvx_scratch=0xd0006000,ext4 or nvx_overlay_upper=ramfs the upper, -# each once. This init assembles only an EROFS image, so it also refuses a GPT -# image. Sets layer_uuid to the filesystem UUID that the image must have, or to -# nothing if the token names none, and upper to scratch or ram. The parser also -# refuses a RAM upper beside a scratch block device, which this init checks -# against the kernel's devices instead, with refuse_scratch_device. The tests -# replay nvx-rs/tests/fixtures/root-tokens.json through it. +# device, nvx_scratch=0xd0006000,ext4 or nvx_overlay_upper=ramfs the upper, and +# nvx_overlay_mode, nvx_scratch_journal, nvx_first_partition_bound, and +# nvx_ram_upper_percent the root's settings, each once. This init assembles +# only an EROFS image, mounts the overlay only for trusted lowers, and reads +# nothing of a scratch's superblock, so after those rules it refuses a GPT +# image, untrusted lowers, and a forbidden journal too. A bound is checked as +# an unsigned decimal even though it needs a GPT image, which this init cannot +# assemble. Sets layer_uuid to the filesystem UUID that the image must have, or to +# nothing if the token names none, upper to scratch or ram, and ram_percent to +# the RAM upper's cap, or to nothing for the kernel's default size. The parser +# also refuses a RAM upper beside a scratch block device, which this init +# checks against the kernel's devices instead, with refuse_scratch_device. The +# tests replay nvx-rs/tests/fixtures/root-tokens.json through it. read_root_tokens() { layer_seen=false scratch_seen=false ram_seen=false layer_uuid= + # The names of the settings that the command line names, and the values of + # three of them. + settings= + overlay_mode= + scratch_journal= + ram_percent= # Kernel command-line tokens are deliberately split on spaces. # shellcheck disable=SC2086 set -f for token in $cmdline; do case "$token" in - nvx_layer | nvx_scratch | nvx_overlay_upper) + nvx_layer | nvx_scratch | nvx_overlay_upper | nvx_overlay_mode | nvx_scratch_journal | nvx_first_partition_bound | nvx_ram_upper_percent) fatal "the $token kernel parameter has no value" ;; nvx_layer=*) @@ -252,6 +263,38 @@ read_root_tokens() { [ "${token#*=}" = ramfs ] || fatal "the nvx_overlay_upper kernel parameter must be ramfs, not '${token#*=}'" ;; + nvx_overlay_mode=* | nvx_scratch_journal=* | nvx_first_partition_bound=* | nvx_ram_upper_percent=*) + setting=${token%%=*} + case " $settings " in *" $setting "*) fatal "the $setting kernel parameter is repeated" ;; esac + settings="$settings $setting" + case "$token" in + nvx_overlay_mode=trusted-lowers | nvx_overlay_mode=untrusted-lowers) overlay_mode=${token#*=} ;; + nvx_overlay_mode=*) fatal "the $setting kernel parameter must be trusted-lowers or untrusted-lowers, not '${token#*=}'" ;; + nvx_scratch_journal=allowed | nvx_scratch_journal=forbidden) scratch_journal=${token#*=} ;; + nvx_scratch_journal=*) fatal "the $setting kernel parameter must be allowed or forbidden, not '${token#*=}'" ;; + nvx_ram_upper_percent=[1-9] | nvx_ram_upper_percent=[1-8][0-9] | nvx_ram_upper_percent=90) ram_percent=${token#*=} ;; + nvx_ram_upper_percent=*) fatal "the $setting kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '${token#*=}'" ;; + nvx_first_partition_bound= | nvx_first_partition_bound=0* | nvx_first_partition_bound=*[!0-9]*) fatal "the $setting kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '${token#*=}'" ;; + nvx_first_partition_bound=*) + bound_digits=${token#*=} + [ "${#bound_digits}" -le 20 ] || + fatal "the $setting kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '${token#*=}'" + if [ "${#bound_digits}" -eq 20 ]; then + bound_limit=18446744073709551615 + # Compare digits, never the full unsigned value. + while [ -n "$bound_digits" ]; do + bound_digit=${bound_digits%"${bound_digits#?}"} + limit_digit=${bound_limit%"${bound_limit#?}"} + [ "$bound_digit" -le "$limit_digit" ] || + fatal "the $setting kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '${token#*=}'" + [ "$bound_digit" -eq "$limit_digit" ] || break + bound_digits=${bound_digits#?} + bound_limit=${bound_limit#?} + done + fi + ;; + esac + ;; esac done set +f @@ -266,6 +309,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 +344,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 +366,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/Cargo.toml b/nvx-rs/Cargo.toml index 804ce2173..131b63937 100644 --- a/nvx-rs/Cargo.toml +++ b/nvx-rs/Cargo.toml @@ -132,6 +132,11 @@ path = "src/bin/nvx-rs.rs" required-features = ["cli"] doc = false +[[example]] +name = "external-launch" +path = "examples/external-launch.rs" +required-features = ["host"] + # The guest binaries. Their unit tests live in the library, so the binaries have no test harness. [[bin]] name = "nvx-console-pending" diff --git a/nvx-rs/README.md b/nvx-rs/README.md index e814382c9..559944eb1 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -111,9 +111,10 @@ changes only when the module's purpose does. | [`nvx::protocol::cmdline`](#nvxprotocolcmdline) | The kernel command line as Linux and the guest's init read it, with the virtio-mmio devices and the guest's DNS server that it names | | [`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::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::protocol::view`](#nvxprotocolview) | A cold boot's hostname bytes and machine-ID mode, with their renderer and strict boot-token parser | | [`nvx::activation`](#nvxactivation) | The activation record of a layered root, which a clone reads in place of its captured configuration, and what Ready reports | | [`nvx::contract`](#nvxcontract) | The versions of the contracts that the crate speaks with OpenVMM and the guest, and the compatibility of releases with them | | [`nvx::net`](#nvxnet) | Network primitives that do not depend on the host: CIDR networks, hostnames, guest addresses, and gateways | @@ -122,8 +123,9 @@ changes only when the module's purpose does. | [`nvx::Hypervisor`](#nvxhypervisor) | KVM, MSHV, or WHP, with its serde form behind the `serde` feature | | [`nvx::exec`](#nvxexec) | The execution engine: the streams, credit, and outcome of an execution's two sides, without I/O, for any framing | | [`nvx::host::runtime`](#nvxhostruntime) | `Runtime`: what every launch shares, the runtime's files and their identity, the hypervisor and its probe, and the launch options | -| [`nvx::host::vm`](#nvxhostvm) | The directory of a VM, private to the current user, with the fixed names of the files and endpoints of its launches | -| [`nvx::host::config`](#nvxhostconfig) | `VmConfig`: a VM's processors, memory, root, view, network, export, boot console, and cold-boot settings as one value, checked without reading the host | +| [`nvx::host::vm`](#nvxhostvm) | A private VM directory and its fixed outputs, and a checked cold launch whose owned command can be handed to the caller's launcher | +| [`nvx::host::snapshot`](#nvxhostsnapshot) | The opaque snapshot input of a future VM restore | +| [`nvx::host::config`](#nvxhostconfig) | `VmConfig`: one VM configuration, pure cold-boot kernel arguments, and the portable command-line check | | [`nvx::host::artifacts`](#nvxhostartifacts) | Locating the OpenVMM executable and the guest artifacts that a VM runs, and their runtime identity | | [`nvx::host::launch`](#nvxhostlaunch) | `LaunchSpec` and `VmmLaunch`: the OpenVMM command line of a managed microVM, its checks, and the inputs that it shares with an adapter's launch | | [`nvx::host::network`](#nvxhostnetwork) | The one lowering of a network posture into OpenVMM options, for the CLI and external orchestrators | @@ -239,16 +241,25 @@ reassembles it. 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` @@ -272,6 +283,21 @@ class of a clone's activation's failures, `ACTIVATION_DEADLINE` to `describe` tells what a status means. +### `nvx::protocol::view` + +`tokens` renders a cold boot's `activation::View`: `nvx_hostname=HEX`, always +lowercase hexadecimal with two digits per hostname byte, then +`nvx_machine_id=image` or `nvx_machine_id=generated`. The hostname keeps the +view's 1 to 64 non-NUL bytes, including whitespace and non-UTF-8 bytes, without +quoting. For example, `box` becomes `nvx_hostname=626f78`, not the legacy +sandbox's raw `nvx_hostname=box`; consumers use the decoded bytes. + +`parse` reads the exact names through `cmdline::init_tokens`, in either order, +and returns a `View`, or `None` when neither token is present. A partial pair, +duplicate, missing value, malformed hexadecimal, overlong hostname, decoded +NUL, or unknown machine-ID mode fails with `ProtocolError`. Restores receive +their view through the activation record instead. + ### `nvx::activation` The activation record of a layered root: one bounded, versioned, non-secret @@ -397,11 +423,13 @@ generation tells stale messages from foreign ones. `Runtime`: what every launch shares, the paths of the OpenVMM executable, guest kernel, and guest initramfs, their `RuntimeIdentity`, the hypervisor, an optional CPU profile, whether OpenVMM breaks away from the caller's Windows job -object, and the `LaunchOptions`. Those are what OpenVMM does when the guest -powers off, a `GuestPowerAction`, by default `Exit`, which ends the VM, or +object, and the `LaunchOptions`. Those are what OpenVMM does on a plain guest +power-off or hibernate event, a `GuestPowerAction`, by default `Exit`, or `Halt` or `Reset`, and how long OpenVMM waits for the host to authenticate on -the control console. The boot console is not among them: each VM attaches its -own. +the control console, in whole milliseconds from 1 to 60000. A power-off that +reports an exit status always exits OpenVMM with that status, regardless of +the selected action. The boot console is not among the options: each VM +attaches its own. `RuntimeIdentity` labels the three files with the `ArtifactIdentity` tokens that their install or staging metadata gives, such as `sha256:` and a digest, which @@ -452,6 +480,53 @@ credential that the host presents at a VM's control endpoint: `as_bytes` returns it, and its `Debug` form hides it. The library never writes it to a file; a caller that reconnects after a restart keeps it. +`vm::create(&runtime, &config, None, &dir)` creates an inspectable cold +`Prepared` launch without starting a process or binding a listener. It requires +`config.boot`, refuses a snapshot input, rechecks the directory's privacy, and +creates a new private log. Existing log, outcome and Linux endpoint entries +are refused rather than replaced. Only the log is created before a spawn; +OpenVMM creates the remaining outputs when it runs. A failed preparation +closes and removes only its new log, preserving all pre-existing entries and +caller-owned inputs, and reports a cleanup failure with the original error. + +On Linux, the generated control and boot-console socket names must be shorter +than 108 bytes and hold no comma or equals sign, which OpenVMM's listener +options cannot represent literally. Creation refuses these names before +creating any output, including the log. + +`Prepared::capability()` and `outputs()` return what the caller may journal +before a spawn. `log()` returns the same opened log, without reopening its +path. `inherit(resource)` moves an already opened resource into the preparation's +inheritance allowlist and keeps it open. `arguments()` returns the whole +configuration's OpenVMM arguments. Creation checks the complete Windows +command line with the actual runtime and VM output paths and no reserve, +including quoting and the terminating NUL, on every host. Callers keep the +directory and runtime inputs stable through creation and a later spawn. +Dropping a successful preparation closes its resources but leaves its output +paths owned by the caller. + +`Prepared::into_command()` moves the complete launch into `VmmCommand`: its +program, arguments, working directory, capability, outputs, inherited resources, +opened log and Windows breakaway setting. It neither reopens nor duplicates +resources, renders again nor rediscovers paths. `InheritedHandle` owns its +resource, converts from and back to `OwnedFd` on Unix or `OwnedHandle` on Windows, +and also accepts an opened `File`. The command documents the caller's spawn +contract, including standard streams, allowlisted inheritance, Linux detachment +and umask, Windows log job and breakaway, and cleanup of a failed child. + +The compiled [external-launch example](examples/external-launch.rs) consumes +this command, uses the existing public low-level spawn primitive without a +sandbox store, checks the cold guest's boot marker and shell response, then asks +it to exit cleanly. Build with `cargo build --locked --example external-launch` +and supply the backend, OpenVMM, kernel, initramfs, new VM directory, and the +three artifact SHA-256 digests from their installation metadata, in that order. + +### `nvx::host::snapshot` + +`Snapshot` is an opaque input of a future restore. `vm::create` currently +refuses `Some(snapshot)` with `ErrorKind::Unsupported`; only cold boots are +supported. + ### `nvx::host::config` `VmConfig`: the configuration of a VM as one value, its processors, its memory @@ -477,15 +552,64 @@ settled on, with its address. A `Boot` holds the caller's own kernel command-line tokens, to which NVX adds those of what it lowers: a VM that boots needs one, and a VM that a snapshot restores takes none. +`VmConfig::kernel_command_line` checks the configuration and requires `Boot`, +then renders the root's tokens and settings, the view's tokens, and `nvx_maps` +when an export exists, followed by accepted `Boot.command_line` text unchanged. +It reads nothing on the host. A restore has no kernel command line from this +method and fails with kind `Unsupported`. + `VmConfig::check` checks the whole value without reading the host: it opens no path and checks no support of the host. It fails with an `nvx::Error` of kind `Unsupported` that names the first refusal: no processor or no memory; a layered root without an image or a scratch path, with a bound of an EROFS image or of 0, or with a RAM upper's cap outside 1 to 90 percent; a hostname that an activation -record refuses; or an export that `ExportPlan::check` refuses for the guest -targets that the root reserves. The network's policy and the boot's command line -are checked where they are lowered, with the guest's address and NVX's own -tokens. +record refuses; an export that `ExportPlan::check` refuses for the guest targets +that the root reserves; or caller boot tokens that use a reserved name. The +network's policy is checked where it is lowered, with the guest's address. + +The reserved boot names are the root's `nvx_layer`, `nvx_scratch`, +`nvx_overlay_upper`, `nvx_overlay_mode`, `nvx_scratch_journal`, +`nvx_first_partition_bound` and `nvx_ram_upper_percent`; the view's +`nvx_hostname` and `nvx_machine_id`; `nvx_maps`; and every name beginning +`virtfs_`. The check reads tokens as both Linux and the guest's init read them, +with dashes and underscores matching as Linux matches parameter names. +Reserved names stay reserved after `--` and when init's splitting exposes them +inside quoted text. Every other token stays caller-owned, including +`nvx_entrypoint`, `nvx_arg` and `nvx_share`. + +`VmConfig::mapping_table` returns exactly the configured +`ExportPlan::guest_table`, in its order, without reading host paths or +revalidating the plan. Without an export it returns an empty table and the +kernel command line omits `nvx_maps`; with an export the token counts those +entries. The table's `HostMap` and its wire codecs are accessible through +`nvx::protocol::maps`, and remain re-exported from `nvx::protocol`. + +`COMMAND_LINE_BUDGET`, in `nvx::host::config`, is the 1,024-byte x86 kernel +command-line budget, including the terminating NUL. The legacy launch checks +continue to use it. + +The internal whole-config renderer used by `vm::create` renders a cold boot's +OpenVMM arguments from `Runtime` and `VmConfig`. Its borrowed control endpoint, +report path, and optional boot-console endpoint are already chosen output names, not additional +launch settings. It opens no path, allocates no endpoint, and starts no process. +It renders processors, memory without narrowing it, kernel and initramfs paths, +the kernel command line, layered-root block devices by path, network and export +arguments, console attachment, the CPU profile, and every `LaunchOptions` value. +A configured NIC remains attached even under deny-all policy. A listener needs +its supplied console name; a disconnected attachment retains the device and +discards its output. The renderer emits neither `--microvm-lifecycle` nor +`--microvm-workload-identity`. Callers inspect the complete arguments through +`Prepared::arguments()`. + +`VmConfig::check_command_line()` checks the configuration's known arguments on +every host, before any host change, with 2048 UTF-16 code units reserved for the +runtime's files, launch options, and VM output names. The reserve foresees file +and endpoint names of up to 260 code units, not arbitrary runtime paths or +CPU-profile strings. The whole renderer checks the complete actual command +line without a reserve too. `PROCESS_COMMAND_LINE_LIMIT`, +`process_command_line_units` and `fit_process_command_line` now belong to +`nvx::host::config`; their existing `nvx::host::launch` paths remain usable, +with the same Windows quoting and terminating-NUL count. ### `nvx::host::artifacts` @@ -526,7 +650,8 @@ specification's. `process_command_line_units` counts the Windows command line that runs a program with its arguments, in UTF-16 code units, as Windows quotes them, and `fit_process_command_line` checks it against `PROCESS_COMMAND_LINE_LIMIT`, on -every host, with the reserve that its caller keeps. +every host, with the reserve that its caller keeps. The helpers live in +`nvx::host::config` and remain re-exported here for existing callers. `LaunchSpec::check_process_command_line`, which a caller runs before it provisions an instance, keeps 1024 code units for what may change before the instance starts, whose own check keeps none. @@ -1633,8 +1758,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/examples/external-launch.rs b/nvx-rs/examples/external-launch.rs new file mode 100644 index 000000000..3a10480c1 --- /dev/null +++ b/nvx-rs/examples/external-launch.rs @@ -0,0 +1,174 @@ +//! A caller-owned cold launch, using the existing low-level spawn primitive without a store. +//! +//! Run the compiled example with a backend, OpenVMM, kernel, initramfs, new VM directory, and +//! the three artifact SHA-256 digests from their installation metadata, in that order. + +use std::error::Error; +use std::io::{self, Write}; +use std::path::Path; +use std::time::{Duration, Instant}; + +use nvx::Hypervisor; +use nvx::activation::{MachineIdMode, View}; +use nvx::host::config::{Boot, ConsoleAttachment, RootConfig, VmConfig}; +use nvx::host::platform::Transport; +use nvx::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; +use nvx::host::supervisor::{self, Inherited}; +use nvx::host::{artifacts, report, session, vm}; + +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args_os().collect(); + if args.len() != 9 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "usage: external-launch BACKEND OPENVMM KERNEL INITRAMFS VM-DIR \ + OPENVMM-SHA256 KERNEL-SHA256 INITRAMFS-SHA256", + ) + .into()); + } + let hypervisor = match args[1].to_str() { + Some("kvm") => Hypervisor::Kvm, + Some("mshv") => Hypervisor::Mshv, + Some("whp") => Hypervisor::Whp, + _ => return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid backend").into()), + }; + nvx::host::runtime::probe_hypervisor(hypervisor)?; + let identity = |index: usize| { + ArtifactIdentity::sha256(args[index].to_str().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "artifact digest is not Unicode", + ) + })?) + .map_err(Box::::from) + }; + let runtime = Runtime { + openvmm: artifacts::absolute(Path::new(&args[2]))?, + kernel: artifacts::absolute(Path::new(&args[3]))?, + initramfs: artifacts::absolute(Path::new(&args[4]))?, + identity: RuntimeIdentity::new(identity(6)?, identity(7)?, identity(8)?), + hypervisor, + cpu_profile: Some("auto".to_owned()), + windows_breakaway: false, + options: LaunchOptions::default(), + }; + let config = VmConfig { + processors: 1, + memory_mib: 256, + root: RootConfig::Blockless, + view: View { + hostname: b"external".to_vec(), + machine_id: MachineIdMode::Image, + }, + network: None, + filesystem: None, + console: ConsoleAttachment::Listener, + boot: Some(Boot { + command_line: "quiet loglevel=0".to_owned(), + }), + }; + vm::create_private_dir(Path::new(&args[5]))?; + let dir = vm::VmDir::open(Path::new(&args[5]))?; + let vm::VmmCommand { + program, + arguments, + working_dir, + capability, + outputs, + inherit, + log, + breakaway_from_job, + } = vm::create(&runtime, &config, None, &dir)?.into_command(); + let command = supervisor::VmmCommand { + program, + arguments, + working_dir, + breakaway_from_job, + inherit: inherit.iter().map(Inherited::new).collect(), + }; + let mut launched = supervisor::spawn(&command, capability.as_bytes(), log)?; + let result = (|| -> Result<(), Box> { + let process = launched.process()?; + let deadline = Instant::now() + Duration::from_secs(60); + let console = outputs.console.as_deref().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "no prepared boot console") + })?; + let mut console = session::connect( + console, + process.reference().pid, + process.reference().start_time, + deadline, + )?; + read_until(console.as_mut(), b"NVX-GUEST-BOOT-OK:", deadline)?; + console.write_all( + b"printf 'NVX-EXTERNAL-LAUNCH-OK\\n'; /sbin/nvx-exit 0\n", + Some(deadline.saturating_duration_since(Instant::now())), + )?; + read_until(console.as_mut(), b"\nNVX-EXTERNAL-LAUNCH-OK", deadline)?; + if !process.wait(deadline)? { + return Err(io::Error::new(io::ErrorKind::TimedOut, "VM did not exit").into()); + } + match launched.wait_timeout(Duration::ZERO)? { + Some(0) => {} + Some(status) => { + return Err(io::Error::other(format!("VMM exited with status {status}")).into()); + } + None => return Err(io::Error::other("VMM exit status is unavailable").into()), + } + let outcome = report::read(&outputs.outcome) + .ok_or_else(|| io::Error::other("missing or invalid VM outcome"))?; + if outcome.category.as_deref() != Some("success") + || outcome.status_code != Some(0) + || outcome.teardown_complete != Some(true) + { + return Err( + io::Error::other(format!("VM did not shut down cleanly: {outcome:?}")).into(), + ); + } + Ok(()) + })(); + if let Err(error) = result { + if !launched.kill_and_wait() { + return Err( + io::Error::other(format!("{error}; could not reap the launched VM")).into(), + ); + } + return Err(error); + } + launched.detach(); + println!( + "external cold launch completed; outcome={}", + outputs.outcome.display() + ); + Ok(()) +} + +/// Prints console output through a marker, retaining only enough bytes to match across reads. +fn read_until(console: &mut dyn Transport, marker: &[u8], deadline: Instant) -> io::Result<()> { + let mut pending = Vec::new(); + let mut buffer = [0; 4096]; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "console marker not received", + )); + } + let count = console.read(&mut buffer, Some(remaining))?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "boot console closed", + )); + } + io::stdout().write_all(&buffer[..count])?; + pending.extend_from_slice(&buffer[..count]); + if pending.windows(marker.len()).any(|bytes| bytes == marker) { + return Ok(()); + } + if pending.len() > marker.len() { + pending.drain(..pending.len() - marker.len()); + } + } +} diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 6be313fd4..94983430a 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) @@ -1752,6 +1755,7 @@ fn nvx::host::cli::main (hidden) mod nvx::host::config struct nvx::host::config::Boot field nvx::host::config::Boot::command_line +const nvx::host::config::COMMAND_LINE_BUDGET enum nvx::host::config::ConsoleAttachment variant nvx::host::config::ConsoleAttachment::Disconnected variant nvx::host::config::ConsoleAttachment::Listener @@ -1767,6 +1771,7 @@ field nvx::host::config::NetworkConfig::guest_network field nvx::host::config::NetworkConfig::policy struct nvx::host::config::NicConfig field nvx::host::config::NicConfig::guest_network +const nvx::host::config::PROCESS_COMMAND_LINE_LIMIT enum nvx::host::config::RootConfig variant nvx::host::config::RootConfig::Blockless variant nvx::host::config::RootConfig::Layered @@ -1781,13 +1786,18 @@ field nvx::host::config::UpperConfig::Scratch::path struct nvx::host::config::VmConfig field nvx::host::config::VmConfig::boot method nvx::host::config::VmConfig::check +method nvx::host::config::VmConfig::check_command_line field nvx::host::config::VmConfig::console field nvx::host::config::VmConfig::filesystem +method nvx::host::config::VmConfig::kernel_command_line +method nvx::host::config::VmConfig::mapping_table field nvx::host::config::VmConfig::memory_mib field nvx::host::config::VmConfig::network field nvx::host::config::VmConfig::processors field nvx::host::config::VmConfig::root field nvx::host::config::VmConfig::view +fn nvx::host::config::fit_process_command_line +fn nvx::host::config::process_command_line_units mod nvx::host::filesystem struct nvx::host::filesystem::Bind field nvx::host::filesystem::Bind::child @@ -1914,7 +1924,6 @@ fn nvx::host::instance::remove_interrupted_writes fn nvx::host::instance::write_json fn nvx::host::instance::write_private mod nvx::host::launch -const nvx::host::launch::COMMAND_LINE_BUDGET const nvx::host::launch::DEFAULT_GUEST_NETWORK const nvx::host::launch::DEFAULT_HOSTNAME const nvx::host::launch::DEFAULT_MEMORY_MIB @@ -2560,6 +2569,8 @@ field nvx::host::session::SessionError::Refused::0 variant nvx::host::session::SessionError::Reset variant nvx::host::session::SessionError::TimedOut fn nvx::host::session::connect +mod nvx::host::snapshot +struct nvx::host::snapshot::Snapshot mod nvx::host::store struct nvx::host::store::ArtifactIdentity method nvx::host::store::ArtifactIdentity::as_str @@ -2698,6 +2709,7 @@ method nvx::host::supervisor::Launched::detach method nvx::host::supervisor::Launched::id method nvx::host::supervisor::Launched::kill_and_wait method nvx::host::supervisor::Launched::process +method nvx::host::supervisor::Launched::wait_timeout enum nvx::host::supervisor::Reattached variant nvx::host::supervisor::Reattached::Exited field nvx::host::supervisor::Reattached::Exited::record @@ -3163,6 +3175,14 @@ fn nvx::host::template::status_evidence mod nvx::host::vm struct nvx::host::vm::Capability method nvx::host::vm::Capability::as_bytes +struct nvx::host::vm::InheritedHandle +struct nvx::host::vm::Prepared +method nvx::host::vm::Prepared::arguments +method nvx::host::vm::Prepared::capability +method nvx::host::vm::Prepared::inherit +method nvx::host::vm::Prepared::into_command +method nvx::host::vm::Prepared::log +method nvx::host::vm::Prepared::outputs struct nvx::host::vm::VmDir method nvx::host::vm::VmDir::open method nvx::host::vm::VmDir::path @@ -3171,6 +3191,16 @@ field nvx::host::vm::VmOutputs::console field nvx::host::vm::VmOutputs::endpoint field nvx::host::vm::VmOutputs::log field nvx::host::vm::VmOutputs::outcome +struct nvx::host::vm::VmmCommand +field nvx::host::vm::VmmCommand::arguments +field nvx::host::vm::VmmCommand::breakaway_from_job +field nvx::host::vm::VmmCommand::capability +field nvx::host::vm::VmmCommand::inherit +field nvx::host::vm::VmmCommand::log +field nvx::host::vm::VmmCommand::outputs +field nvx::host::vm::VmmCommand::program +field nvx::host::vm::VmmCommand::working_dir +fn nvx::host::vm::create fn nvx::host::vm::create_private_dir mod nvx::net struct nvx::net::Cidr @@ -3382,6 +3412,29 @@ struct nvx::protocol::export::HostExport field nvx::protocol::export::HostExport::mode method nvx::protocol::export::HostExport::parse field nvx::protocol::export::HostExport::tag +mod nvx::protocol::maps +struct nvx::protocol::maps::GuestHostMap +field nvx::protocol::maps::GuestHostMap::read_only +field nvx::protocol::maps::GuestHostMap::source +field nvx::protocol::maps::GuestHostMap::target +struct nvx::protocol::maps::GuestMapsPayload +field nvx::protocol::maps::GuestMapsPayload::first +field nvx::protocol::maps::GuestMapsPayload::maps +struct nvx::protocol::maps::HostMap +field nvx::protocol::maps::HostMap::read_only +field nvx::protocol::maps::HostMap::source +field nvx::protocol::maps::HostMap::target +const nvx::protocol::maps::MAPS_HEADER_LEN +const nvx::protocol::maps::MAP_ENTRY_HEADER_LEN +const nvx::protocol::maps::MAP_READ_ONLY +const nvx::protocol::maps::MAX_HOST_MAPPINGS +const nvx::protocol::maps::MAX_MAP_PATH_BYTES +struct nvx::protocol::maps::MapsPayload +field nvx::protocol::maps::MapsPayload::first +field nvx::protocol::maps::MapsPayload::maps +fn nvx::protocol::maps::decode_guest_maps_payload +fn nvx::protocol::maps::decode_maps_payload +fn nvx::protocol::maps::encode_maps_payloads mod nvx::protocol::nvxs enum nvx::protocol::nvxs::ErrorCode variant nvx::protocol::nvxs::ErrorCode::Authentication @@ -3527,12 +3580,15 @@ fn nvx::protocol::nvxs::encode_record fn nvx::protocol::parse_host_mapping_count 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 @@ -3540,7 +3596,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 @@ -3548,6 +3608,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 @@ -3582,6 +3643,11 @@ const nvx::protocol::status::TIME_REPAIR const nvx::protocol::status::TIME_STATUSES const nvx::protocol::status::TIME_VIOLATION fn nvx::protocol::status::describe +mod nvx::protocol::view +const nvx::protocol::view::HOSTNAME_TOKEN +const nvx::protocol::view::MACHINE_ID_TOKEN +fn nvx::protocol::view::parse +fn nvx::protocol::view::tokens fn nvx::resolve_guest_path mod nvx::testing (hidden) mod nvx::testing::fake_openvmm (hidden) diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index 0c4b96d4d..d348f1003 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -678,6 +678,7 @@ fn nvx::host::cli::main (hidden) mod nvx::host::config struct nvx::host::config::Boot field nvx::host::config::Boot::command_line +const nvx::host::config::COMMAND_LINE_BUDGET enum nvx::host::config::ConsoleAttachment variant nvx::host::config::ConsoleAttachment::Disconnected variant nvx::host::config::ConsoleAttachment::Listener @@ -693,6 +694,7 @@ field nvx::host::config::NetworkConfig::guest_network field nvx::host::config::NetworkConfig::policy struct nvx::host::config::NicConfig field nvx::host::config::NicConfig::guest_network +const nvx::host::config::PROCESS_COMMAND_LINE_LIMIT enum nvx::host::config::RootConfig variant nvx::host::config::RootConfig::Blockless variant nvx::host::config::RootConfig::Layered @@ -707,13 +709,18 @@ field nvx::host::config::UpperConfig::Scratch::path struct nvx::host::config::VmConfig field nvx::host::config::VmConfig::boot method nvx::host::config::VmConfig::check +method nvx::host::config::VmConfig::check_command_line field nvx::host::config::VmConfig::console field nvx::host::config::VmConfig::filesystem +method nvx::host::config::VmConfig::kernel_command_line +method nvx::host::config::VmConfig::mapping_table field nvx::host::config::VmConfig::memory_mib field nvx::host::config::VmConfig::network field nvx::host::config::VmConfig::processors field nvx::host::config::VmConfig::root field nvx::host::config::VmConfig::view +fn nvx::host::config::fit_process_command_line +fn nvx::host::config::process_command_line_units mod nvx::host::filesystem struct nvx::host::filesystem::Bind field nvx::host::filesystem::Bind::child @@ -840,7 +847,6 @@ fn nvx::host::instance::remove_interrupted_writes fn nvx::host::instance::write_json fn nvx::host::instance::write_private mod nvx::host::launch -const nvx::host::launch::COMMAND_LINE_BUDGET const nvx::host::launch::DEFAULT_GUEST_NETWORK const nvx::host::launch::DEFAULT_HOSTNAME const nvx::host::launch::DEFAULT_MEMORY_MIB @@ -1486,6 +1492,8 @@ field nvx::host::session::SessionError::Refused::0 variant nvx::host::session::SessionError::Reset variant nvx::host::session::SessionError::TimedOut fn nvx::host::session::connect +mod nvx::host::snapshot +struct nvx::host::snapshot::Snapshot mod nvx::host::store struct nvx::host::store::ArtifactIdentity method nvx::host::store::ArtifactIdentity::as_str @@ -1623,6 +1631,7 @@ method nvx::host::supervisor::Launched::detach method nvx::host::supervisor::Launched::id method nvx::host::supervisor::Launched::kill_and_wait method nvx::host::supervisor::Launched::process +method nvx::host::supervisor::Launched::wait_timeout enum nvx::host::supervisor::Reattached variant nvx::host::supervisor::Reattached::Exited field nvx::host::supervisor::Reattached::Exited::record @@ -2088,6 +2097,14 @@ fn nvx::host::template::status_evidence mod nvx::host::vm struct nvx::host::vm::Capability method nvx::host::vm::Capability::as_bytes +struct nvx::host::vm::InheritedHandle +struct nvx::host::vm::Prepared +method nvx::host::vm::Prepared::arguments +method nvx::host::vm::Prepared::capability +method nvx::host::vm::Prepared::inherit +method nvx::host::vm::Prepared::into_command +method nvx::host::vm::Prepared::log +method nvx::host::vm::Prepared::outputs struct nvx::host::vm::VmDir method nvx::host::vm::VmDir::open method nvx::host::vm::VmDir::path @@ -2096,6 +2113,16 @@ field nvx::host::vm::VmOutputs::console field nvx::host::vm::VmOutputs::endpoint field nvx::host::vm::VmOutputs::log field nvx::host::vm::VmOutputs::outcome +struct nvx::host::vm::VmmCommand +field nvx::host::vm::VmmCommand::arguments +field nvx::host::vm::VmmCommand::breakaway_from_job +field nvx::host::vm::VmmCommand::capability +field nvx::host::vm::VmmCommand::inherit +field nvx::host::vm::VmmCommand::log +field nvx::host::vm::VmmCommand::outputs +field nvx::host::vm::VmmCommand::program +field nvx::host::vm::VmmCommand::working_dir +fn nvx::host::vm::create fn nvx::host::vm::create_private_dir mod nvx::net struct nvx::net::Cidr @@ -2307,6 +2334,29 @@ struct nvx::protocol::export::HostExport field nvx::protocol::export::HostExport::mode method nvx::protocol::export::HostExport::parse field nvx::protocol::export::HostExport::tag +mod nvx::protocol::maps +struct nvx::protocol::maps::GuestHostMap +field nvx::protocol::maps::GuestHostMap::read_only +field nvx::protocol::maps::GuestHostMap::source +field nvx::protocol::maps::GuestHostMap::target +struct nvx::protocol::maps::GuestMapsPayload +field nvx::protocol::maps::GuestMapsPayload::first +field nvx::protocol::maps::GuestMapsPayload::maps +struct nvx::protocol::maps::HostMap +field nvx::protocol::maps::HostMap::read_only +field nvx::protocol::maps::HostMap::source +field nvx::protocol::maps::HostMap::target +const nvx::protocol::maps::MAPS_HEADER_LEN +const nvx::protocol::maps::MAP_ENTRY_HEADER_LEN +const nvx::protocol::maps::MAP_READ_ONLY +const nvx::protocol::maps::MAX_HOST_MAPPINGS +const nvx::protocol::maps::MAX_MAP_PATH_BYTES +struct nvx::protocol::maps::MapsPayload +field nvx::protocol::maps::MapsPayload::first +field nvx::protocol::maps::MapsPayload::maps +fn nvx::protocol::maps::decode_guest_maps_payload +fn nvx::protocol::maps::decode_maps_payload +fn nvx::protocol::maps::encode_maps_payloads mod nvx::protocol::nvxs enum nvx::protocol::nvxs::ErrorCode variant nvx::protocol::nvxs::ErrorCode::Authentication @@ -2452,12 +2502,15 @@ fn nvx::protocol::nvxs::encode_record fn nvx::protocol::parse_host_mapping_count 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 @@ -2465,7 +2518,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 @@ -2473,6 +2530,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 @@ -2507,6 +2565,11 @@ const nvx::protocol::status::TIME_REPAIR const nvx::protocol::status::TIME_STATUSES const nvx::protocol::status::TIME_VIOLATION fn nvx::protocol::status::describe +mod nvx::protocol::view +const nvx::protocol::view::HOSTNAME_TOKEN +const nvx::protocol::view::MACHINE_ID_TOKEN +fn nvx::protocol::view::parse +fn nvx::protocol::view::tokens fn nvx::resolve_guest_path mod nvx::testing (hidden) mod nvx::testing::fake_openvmm (hidden) 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/config/mod.rs b/nvx-rs/src/host/config/mod.rs index 9825e42e7..d814f95fd 100644 --- a/nvx-rs/src/host/config/mod.rs +++ b/nvx-rs/src/host/config/mod.rs @@ -1,17 +1,37 @@ //! The configuration of a VM, as one value that [`VmConfig::check`] checks without reading the //! host: the VM's processors and memory, its [`RootConfig`], the [`View`] that its workload has //! of it, its [`NetworkConfig`], the host paths that it exports, its [`ConsoleAttachment`], and -//! what only its cold boot takes, a [`Boot`]. +//! what only its cold boot takes, a [`Boot`]. An internal renderer builds a whole +//! cold launch for [`crate::host::vm::create`], from that configuration and a +//! [`Runtime`], without opening runtime paths or allocating endpoints. +use std::ffi::{OsStr, OsString}; use std::ops::RangeInclusive; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::time::Duration; use super::filesystem::{ExportPlan, ReservedTargets}; -use super::network::NetworkPolicy; +use super::network::{self, NetworkPolicy}; +use super::runtime::{GuestPowerAction, Runtime}; use crate::activation::{self, Journal, MAX_HOSTNAME_BYTES, OverlayMode, View}; use crate::error::{Error, Result}; +use crate::hypervisor::Hypervisor; use crate::net::Ipv4Cidr; -use crate::protocol::root::{ImageFormat, RootImage}; +use crate::protocol::cmdline::{self, Parameter}; +use crate::protocol::maps::HostMap; +use crate::protocol::root::{ImageFormat, RootImage, RootLayout, RootUpper}; +use crate::protocol::view; + +/// Bytes of the x86 kernel command line available to caller tokens, including the NUL. +pub const COMMAND_LINE_BUDGET: usize = 1024; + +/// Longest Windows process command line, in UTF-16 code units, including the terminating NUL. +/// +/// OpenVMM's command line is held to it on every host, so a policy behaves alike on all of them. +pub const PROCESS_COMMAND_LINE_LIMIT: usize = 32_767; + +/// Room for the runtime's files and the VM's output paths, as legacy adapter planning reserves. +pub(crate) const OPENVMM_COMMAND_LINE_RESERVE: usize = 2048; /// The guest address of a VM's NIC at a cold boot whose configuration names none, in the form /// that [`Ipv4Cidr::parse`] reads. @@ -119,10 +139,274 @@ pub struct NicConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct Boot { /// The caller's own kernel command-line tokens, to which NVX adds those of what it lowers. + /// The root's tokens, `nvx_hostname`, `nvx_machine_id`, `nvx_maps` and `virtfs_*` are reserved; + /// every other token, including `nvx_entrypoint`, `nvx_arg` and `nvx_share`, is caller-owned. pub command_line: String, } impl VmConfig { + /// Returns the configured export's mapping table, in exactly the order and form of + /// [`ExportPlan::guest_table`], or an empty table without an export. + /// + /// This is a pure projection: it reads no host path and does not recheck the plan. A cold + /// boot's rendered `nvx_maps` token counts these entries. + pub fn mapping_table(&self) -> Vec { + self.filesystem + .as_ref() + .map_or_else(Vec::new, ExportPlan::guest_table) + } + + /// Renders a cold boot's kernel command line without reading the host: the root's tokens, + /// the view's tokens, the export's mapping count when present, then the caller's + /// [`Boot::command_line`] without trimming or rewriting it. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) when + /// [`check`](Self::check) refuses the configuration or when it has no [`Boot`]. A restore + /// receives its view through its activation record, not a kernel command line. + pub fn kernel_command_line(&self) -> Result { + self.check()?; + let boot = self + .boot + .as_ref() + .ok_or_else(|| Error::unsupported("a kernel command line needs a cold boot's Boot"))?; + let mut tokens = Vec::new(); + if let RootConfig::Layered(root) = &self.root { + let (upper, journal, max_memory_percent) = match &root.upper { + UpperConfig::Scratch { journal, .. } => (RootUpper::Scratch, Some(*journal), None), + UpperConfig::Ram { max_memory_percent } => { + (RootUpper::Ram, None, Some(*max_memory_percent)) + } + }; + tokens.extend( + RootLayout { + image: root.format, + upper, + overlay: Some(root.overlay), + journal, + gpt_first_partition_bound: root.gpt_first_partition_bound, + max_memory_percent, + } + .tokens(), + ); + } + tokens.extend( + view::tokens(&self.view) + .map_err(|error| Error::unsupported(error.to_string()).with_source(error))?, + ); + if let Some(export) = &self.filesystem { + tokens.push(export.kernel_token()); + } + if !boot.command_line.is_empty() { + tokens.push(boot.command_line.clone()); + } + Ok(tokens.join(" ")) + } + + /// Renders a cold boot's complete OpenVMM arguments from this configuration and `runtime`. + /// + /// `endpoint`, `report` and `console_endpoint` are already chosen output names, not launch + /// settings: this method creates no directory, file, listener, or process, and uses paths as + /// given. A [`ConsoleAttachment::Listener`] needs a nonempty `console_endpoint`; a + /// [`ConsoleAttachment::Disconnected`] needs none and retains the console device with a + /// discarded attachment. The control endpoint always listens and authenticates from stdin. + /// Listener names must be distinct, with ASCII-case-insensitive comparison for WHP. + /// The caller runs `runtime.openvmm` with these arguments. + /// + /// [`crate::host::vm::create`] calls this internal whole-config renderer. Neither managed + /// lifecycle nor workload-identity flags are rendered; the caller owns its guest tokens. + /// `runtime.windows_breakaway` is process-launch behavior, not an OpenVMM argument. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a refused + /// configuration, no [`Boot`], empty or NUL-containing required names or runtime paths, + /// duplicate listener names, block paths that are not Unicode or hold disk-option delimiters + /// or unbalanced brackets, an invalid authentication timeout, kernel text with double quotes + /// or the standalone `--` delimiter, or a complete command line beyond the Windows limit. + /// The complete limit check runs on every host, without a reserve. + pub(crate) fn openvmm_arguments( + &self, + runtime: &Runtime, + endpoint: &str, + report: &Path, + console_endpoint: Option<&str>, + ) -> Result> { + let command_line = self.control_kernel_command_line()?; + check_endpoint(endpoint, "endpoint")?; + for (name, path) in [ + ("Runtime.openvmm", runtime.openvmm.as_path()), + ("Runtime.kernel", runtime.kernel.as_path()), + ("Runtime.initramfs", runtime.initramfs.as_path()), + ("report", report), + ] { + if path.as_os_str().is_empty() || path.as_os_str().to_string_lossy().contains('\0') { + return Err(Error::unsupported(format!( + "{name} must name a path without a NUL" + ))); + } + } + let console = match (self.console, console_endpoint) { + (ConsoleAttachment::Listener, Some(console_endpoint)) => { + check_endpoint(console_endpoint, "console_endpoint")?; + if console_endpoint == endpoint + || (runtime.hypervisor == Hypervisor::Whp + && console_endpoint.eq_ignore_ascii_case(endpoint)) + { + return Err(Error::unsupported( + "boot and control console listeners require distinct endpoints", + )); + } + format!("listen={console_endpoint}") + } + (ConsoleAttachment::Disconnected, None) => "none".to_owned(), + (ConsoleAttachment::Listener, None) => { + return Err(Error::unsupported( + "a console listener needs console_endpoint", + )); + } + (ConsoleAttachment::Disconnected, Some(_)) => { + return Err(Error::unsupported( + "a disconnected console takes no console_endpoint", + )); + } + }; + let mut arguments = self.device_arguments()?; + arguments.extend(boot_arguments( + runtime.hypervisor, + self.memory_mib, + &runtime.kernel, + &runtime.initramfs, + &command_line, + )); + arguments.extend(console_arguments(&console, endpoint, report)); + if let Some(profile) = &runtime.cpu_profile { + if profile.is_empty() { + return Err(Error::unsupported("Runtime.cpu_profile must not be empty")); + } + arguments.extend(["--cpu-profile".into(), profile.as_str().into()]); + } + let shutdown = match runtime.options.guest_shutdown { + GuestPowerAction::Exit => "exit", + GuestPowerAction::Halt => "halt", + GuestPowerAction::Reset => "reset", + }; + arguments.extend(["--guest-shutdown-action".into(), shutdown.into()]); + if let Some(timeout) = runtime.options.control_auth_timeout { + if timeout < Duration::from_millis(1) + || timeout > Duration::from_secs(60) + || timeout.subsec_nanos() % 1_000_000 != 0 + { + return Err(Error::unsupported( + "LaunchOptions.control_auth_timeout must be whole milliseconds from 1 to 60000", + )); + } + arguments.extend([ + "--microvm-control-auth-timeout-ms".into(), + timeout.as_millis().to_string().into(), + ]); + } + check_argument_nuls(&arguments)?; + fit_process_command_line(runtime.openvmm.as_os_str(), &arguments, 0)?; + Ok(arguments) + } + + /// Checks the configuration's part of a cold boot's OpenVMM command line on every host, + /// without reading or changing the host, with 2048 UTF-16 code units reserved for the + /// runtime's files, launch options, and the VM's output paths. + /// + /// Like legacy adapter planning, the reserve foresees runtime-file and output names of up to + /// 260 code units each, Windows' `MAX_PATH`. It does not certify arbitrary runtime paths or + /// CPU-profile names: [`crate::host::vm::create`] checks the complete command line with the + /// actual runtime and output names, without a reserve. + /// A listener's backend prefix is counted here; its endpoint name stays in the reserve. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a refused + /// configuration or no [`Boot`], an unlowerable network policy, a NUL-containing argument, + /// a non-Unicode block path or one that OpenVMM's disk syntax cannot hold literally, kernel + /// text with double quotes or the standalone `--` delimiter, or insufficient room for the + /// reserve. + pub fn check_command_line(&self) -> Result<()> { + let command_line = self.control_kernel_command_line()?; + let mut arguments = self.device_arguments()?; + arguments.extend([ + "--memory".into(), + format!("{}M", self.memory_mib).into(), + "--cmdline".into(), + command_line.into(), + "--virtio-console".into(), + match self.console { + ConsoleAttachment::Listener => "listen=", + ConsoleAttachment::Disconnected => "none", + } + .into(), + ]); + check_argument_nuls(&arguments)?; + fit_arguments(&arguments, OPENVMM_COMMAND_LINE_RESERVE) + } + + /// Renders kernel text without the double quotes or delimiter OpenVMM's control console refuses. + fn control_kernel_command_line(&self) -> Result { + let command_line = self.kernel_command_line()?; + if command_line.contains('"') + || command_line + .split_ascii_whitespace() + .any(|token| token == "--") + { + return Err(Error::unsupported( + "boot.command_line cannot contain double quotes or a standalone -- with the \ + OpenVMM control console", + )); + } + Ok(command_line) + } + + /// Renders only the configuration's devices; no runtime or output name is needed. + fn device_arguments(&self) -> Result> { + let mut arguments = vec![ + "--machine".into(), + "microvm".into(), + "--processors".into(), + self.processors.to_string().into(), + ]; + if let RootConfig::Layered(root) = &self.root { + let scratch = match &root.upper { + UpperConfig::Scratch { path, .. } => Some(path), + UpperConfig::Ram { .. } => None, + }; + for path in [Some(&root.image), scratch].into_iter().flatten() { + if path.to_str().is_none() { + return Err(Error::unsupported( + "block paths must be Unicode for OpenVMM's string arguments", + )); + } + reject_disk_path(path, &path.display().to_string())?; + } + let mut image = OsString::from("distro:file:"); + image.push(&root.image); + image.push(",ro"); + arguments.extend(["--microvm-sandbox-block".into(), image]); + if let UpperConfig::Scratch { path, .. } = &root.upper { + let mut scratch = OsString::from("scratch:file:"); + scratch.push(path); + arguments.extend(["--microvm-sandbox-block".into(), scratch]); + } + } + if let Some(network) = &self.network { + let guest_network = network.guest_network.map_or_else( + || DEFAULT_GUEST_NETWORK.to_owned(), + |address| address.to_string(), + ); + let mut nic = network::openvmm_arguments(Some(&network.policy), &guest_network)?; + if nic.is_empty() { + // A configured NIC remains present even under the deny-all posture. + nic = network::template_arguments(&guest_network); + } + arguments.extend(nic.into_iter().map(OsString::from)); + } + if let Some(export) = &self.filesystem { + arguments.extend(export.openvmm_arguments().into_iter().map(OsString::from)); + } + Ok(arguments) + } + /// Checks the whole configuration without reading the host: it opens no path, and checks no /// support of the host. /// @@ -130,11 +414,14 @@ impl VmConfig { /// refusal in the order of the fields: no processor; no memory; a layered root without an image /// path, with a bound of an EROFS image's first partition or a bound of 0, with a scratch upper /// without a path, or with a RAM upper whose cap is not 1 to 90 percent; a hostname that is - /// empty, longer than [`MAX_HOSTNAME_BYTES`], or holds a NUL; and an export that - /// [`ExportPlan::check`] refuses for the root, whose error is the source. + /// empty, longer than [`MAX_HOSTNAME_BYTES`], or holds a NUL; an export that + /// [`ExportPlan::check`] refuses for the root, whose error is the source; and caller boot + /// tokens that collide with the root's, the view's, `nvx_maps`, or any `virtfs_*` name. /// - /// The NIC's address is checked when it is built. The network's policy and the boot's command - /// line are checked where they are lowered, with the guest's address and NVX's own tokens. + /// The NIC's address is checked when it is built, and the network's policy where it is + /// lowered, with the guest's address. Boot tokens are read as both Linux and the guest's init + /// read them, with dashes and underscores matching as Linux matches parameter names. + /// [`kernel_command_line`](Self::kernel_command_line) appends accepted caller text unchanged. pub fn check(&self) -> Result<()> { if self.processors == 0 { return Err(Error::unsupported("a VM needs at least one processor")); @@ -159,6 +446,36 @@ impl VmConfig { Error::unsupported("the export plan is invalid").with_source(error) })?; } + if let Some(boot) = &self.boot { + let guest = cmdline::init_tokens(boot.command_line.as_bytes()).map(|token| Parameter { + name: token.name, + value: token.value, + }); + for parameter in cmdline::parameters(boot.command_line.as_bytes()).chain(guest) { + if [ + "nvx_layer", + "nvx_scratch", + "nvx_overlay_upper", + "nvx_overlay_mode", + "nvx_scratch_journal", + "nvx_first_partition_bound", + "nvx_ram_upper_percent", + "nvx_hostname", + "nvx_machine_id", + "nvx_maps", + ] + .iter() + .any(|name| parameter.is_named(name)) + || parameter.name.starts_with(b"virtfs_") + || parameter.name.starts_with(b"virtfs-") + { + return Err(Error::unsupported(format!( + "Boot.command_line uses the reserved token {:?}", + String::from_utf8_lossy(parameter.name) + ))); + } + } + } Ok(()) } } @@ -200,5 +517,184 @@ impl LayeredRoot { } } +/// Refuses a disk path that OpenVMM's `--microvm-sandbox-block` value could not hold: one with +/// a comma, semicolon, or improperly nested brackets. The message shows `path` as `shown`. +pub(crate) fn reject_disk_path(path: &Path, shown: &str) -> Result<()> { + let depth = path + .to_string_lossy() + .bytes() + .try_fold(0_usize, |depth, byte| match byte { + b'[' => Some(depth + 1), + b']' => depth.checked_sub(1), + b',' | b';' => None, + _ => Some(depth), + }); + if depth != Some(0) { + return Err(Error::unsupported(format!( + "disk paths containing commas, semicolons, or unbalanced brackets are unsupported: \ + {shown}" + ))); + } + Ok(()) +} + +/// Common cold-boot inputs, shared with the legacy VmmLaunch without narrowing its public types. +pub(crate) fn boot_arguments( + hypervisor: Hypervisor, + memory_mib: u64, + kernel: &Path, + initrd: &Path, + kernel_command_line: &str, +) -> Vec { + let memory = format!("{memory_mib}M"); + [ + OsStr::new("--single-process"), + OsStr::new("--hypervisor"), + OsStr::new(hypervisor.as_str()), + OsStr::new("--memory"), + OsStr::new(&memory), + OsStr::new("--kernel"), + kernel.as_os_str(), + OsStr::new("--initrd"), + initrd.as_os_str(), + OsStr::new("--cmdline"), + OsStr::new(kernel_command_line), + ] + .map(OsStr::to_os_string) + .into() +} + +/// Common console, control authentication, and report arguments of a cold launch. +pub(crate) fn console_arguments(console: &str, endpoint: &str, report: &Path) -> Vec { + let control = format!("listen={endpoint}"); + [ + OsStr::new("--virtio-console"), + OsStr::new(console), + OsStr::new("--microvm-control-console"), + OsStr::new(&control), + OsStr::new("--microvm-control-auth-stdin"), + OsStr::new("--microvm-report"), + report.as_os_str(), + ] + .map(OsStr::to_os_string) + .into() +} + +/// Refuses empty or NUL-containing caller-selected endpoint names. +fn check_endpoint(endpoint: &str, name: &str) -> Result<()> { + if endpoint.is_empty() || endpoint.contains('\0') { + return Err(Error::unsupported(format!( + "{name} must name a nonempty endpoint without a NUL", + ))); + } + Ok(()) +} + +/// Checks OpenVMM arguments for NULs that cannot be passed to a process. +fn check_argument_nuls(arguments: &[OsString]) -> Result<()> { + if arguments + .iter() + .any(|argument| argument.to_string_lossy().contains('\0')) + { + return Err(Error::unsupported( + "OpenVMM's arguments must not contain a NUL", + )); + } + Ok(()) +} + +/// Returns the length in UTF-16 code units, including the terminating NUL, of the Windows command +/// line that runs `program` with `arguments`, quoted the way the Windows platform quotes it: the +/// program always in quotes, and each argument in quotes if it is empty or holds a space or a +/// tab, with a backslash before each quote that it holds and a backslash before each backslash +/// that precedes a quote, its closing quote included, and one space between them. +/// +/// It is the length that [`PROCESS_COMMAND_LINE_LIMIT`] bounds, whatever the host: +/// [`fit_process_command_line`] checks it. +pub fn process_command_line_units(program: &OsStr, arguments: &[OsString]) -> usize { + argument_units(program, true) + arguments_units(arguments) +} + +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) unless the Windows command +/// line that runs `program`, such as OpenVMM, with `arguments`, as [`process_command_line_units`] +/// counts it, leaves `reserve` code units of [`PROCESS_COMMAND_LINE_LIMIT`] unused: none for the +/// complete command line of a launch about to spawn OpenVMM, and some for what may change before +/// then, such as the paths of the artifacts. +/// +/// The error says how long the command line is, and how to shorten it. +pub fn fit_process_command_line( + program: &OsStr, + arguments: &[OsString], + reserve: usize, +) -> Result<()> { + let length = process_command_line_units(program, arguments); + let held = if reserve == 0 { + String::new() + } else { + format!(", with {reserve} held in reserve") + }; + fit("OpenVMM's command line", length, reserve, &held) +} + +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) unless OpenVMM's +/// `arguments`, which a plan renders, without the program and the rest of the launch, leave +/// `reserve` code units of [`PROCESS_COMMAND_LINE_LIMIT`] to that rest. +pub(crate) fn fit_arguments(arguments: &[OsString], reserve: usize) -> Result<()> { + let held = format!(", with {reserve} held in reserve for the rest of the launch"); + fit( + "OpenVMM's arguments", + arguments_units(arguments), + reserve, + &held, + ) +} + +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), saying that `subject` +/// would take `length` code units, `held` describing `reserve`, unless they leave `reserve` of +/// [`PROCESS_COMMAND_LINE_LIMIT`] unused. +fn fit(subject: &str, length: usize, reserve: usize, held: &str) -> Result<()> { + if length.saturating_add(reserve) <= PROCESS_COMMAND_LINE_LIMIT { + return Ok(()); + } + Err(Error::unsupported(format!( + "{subject} would take {length} of the {PROCESS_COMMAND_LINE_LIMIT} characters that a \ + Windows process command line allows{held}; map, hide, or allow fewer or shorter \ + filesystem paths, or use fewer network rules" + ))) +} + +/// Returns the length in UTF-16 code units of `arguments` on a Windows command line, quoted as +/// [`process_command_line_units`] quotes them, each with the space before it. +pub(crate) fn arguments_units(arguments: &[OsString]) -> usize { + arguments + .iter() + .map(|argument| argument_units(argument, false)) + .sum() +} + +/// Returns the length in UTF-16 code units of `argument` on a Windows command line, quoted as +/// [`process_command_line_units`] quotes it, always if it is the `program`, with one separating +/// space, or the terminating NUL after the last argument. +fn argument_units(argument: &OsStr, program: bool) -> usize { + let text = argument.to_string_lossy(); + let quoted = program || text.is_empty() || text.contains([' ', '\t']); + let mut length = 1 + text.encode_utf16().count() + if quoted { 2 } else { 0 }; + let mut backslashes = 0; + for character in text.chars() { + match character { + '\\' => backslashes += 1, + '"' => { + length += backslashes + 1; + backslashes = 0; + } + _ => backslashes = 0, + } + } + if quoted { + length += backslashes; + } + length +} + #[cfg(test)] mod tests; diff --git a/nvx-rs/src/host/config/tests.rs b/nvx-rs/src/host/config/tests.rs index 09f1f1dd5..ecf2db5bb 100644 --- a/nvx-rs/src/host/config/tests.rs +++ b/nvx-rs/src/host/config/tests.rs @@ -6,8 +6,14 @@ use std::error::Error as _; use super::*; use crate::ErrorKind; use crate::activation::MachineIdMode; +use crate::host::artifacts::Artifacts; use crate::host::filesystem::{Bind, ExportChild, Owner}; -use crate::host::network::Access; +use crate::host::launch::LaunchSpec; +use crate::host::network::{Access, EgressRule, Protocol}; +use crate::host::runtime::{ArtifactIdentity, LaunchOptions, RuntimeIdentity}; +use crate::host::sandbox::{SandboxDistro, SandboxPlan, SandboxSettings, SandboxUpper}; +use crate::hypervisor::Hypervisor; +use crate::protocol::{cmdline, parse_host_mapping_count}; use crate::uuid::Uuid; /// Returns the path of `name` in a directory that doesn't exist. @@ -255,3 +261,1107 @@ fn an_export_passes_the_check_of_the_targets_that_its_root_reserves() { assert!(source.contains(target), "{source}"); } } + +impl VmConfig { + fn legacy_command_line(&self) -> String { + let hostname = String::from_utf8(self.view.hostname.clone()).unwrap(); + match &self.root { + RootConfig::Blockless => { + let mut spec = LaunchSpec::new( + Artifacts::new("/openvmm", "/vmlinux", "/initramfs"), + Hypervisor::Kvm, + ); + spec.hostname = hostname; + spec.filesystem = self.filesystem.clone(); + spec.kernel_command_line().unwrap() + } + RootConfig::Layered(root) => { + let upper = match &root.upper { + UpperConfig::Scratch { path, .. } => SandboxUpper::scratch_at(path.clone()), + UpperConfig::Ram { .. } => SandboxUpper::RAM, + }; + SandboxPlan::new( + SandboxDistro::at(root.image.clone(), root.format), + upper, + SandboxSettings { + hostname, + ..SandboxSettings::default() + }, + ) + .unwrap() + .with_export(self.filesystem.clone()) + .unwrap() + .kernel_command_line("") + .unwrap() + } + } + } +} + +#[test] +fn cold_command_lines_keep_the_old_root_view_and_mapping_values() { + let mut configs = vec![blockless()]; + let uppers = [ + UpperConfig::Scratch { + path: missing("scratch.ext4"), + journal: Journal::Allowed, + }, + UpperConfig::Scratch { + path: missing("scratch.ext4"), + journal: Journal::Forbidden, + }, + UpperConfig::Ram { + max_memory_percent: 1, + }, + UpperConfig::Ram { + max_memory_percent: 90, + }, + ]; + for format in [ImageFormat::Erofs, ImageFormat::Gpt] { + for identity in [None, Some(Uuid::from_bytes([7; 16]))] { + for overlay in [OverlayMode::TrustedLowers, OverlayMode::UntrustedLowers] { + for upper in &uppers { + for bound in [None, Some(64 * 1024)] { + if format == ImageFormat::Erofs && bound.is_some() { + continue; + } + configs.push(layered(LayeredRoot { + format: RootImage { format, identity }, + gpt_first_partition_bound: bound, + upper: upper.clone(), + overlay, + ..erofs() + })); + } + } + } + } + } + let mut mappings = export("/work"); + mappings.binds.push(Bind { + target: "/zother".to_owned(), + ..mappings.binds[0].clone() + }); + for config in configs { + for filesystem in [None, Some(mappings.clone())] { + for machine_id in [MachineIdMode::Image, MachineIdMode::Generated] { + let mut config = VmConfig { + filesystem: filesystem.clone(), + boot: Some(Boot { + command_line: String::new(), + }), + ..config.clone() + }; + config.view.machine_id = machine_id; + let old = config.legacy_command_line(); + let actual = config.kernel_command_line().unwrap(); + let old_root = RootLayout::parse(old.as_bytes()).unwrap(); + let root = RootLayout::parse(actual.as_bytes()).unwrap(); + assert_eq!( + root.map(|root| (root.image, root.upper)), + old_root.map(|root| (root.image, root.upper)) + ); + if let Some(old_root) = old_root { + assert!(actual.starts_with(&old_root.tokens().join(" "))); + } + if let RootConfig::Layered(configured) = &config.root { + let root = root.unwrap(); + assert_eq!(root.overlay, Some(configured.overlay)); + assert_eq!( + root.gpt_first_partition_bound, + configured.gpt_first_partition_bound + ); + match configured.upper { + UpperConfig::Scratch { journal, .. } => { + assert_eq!(root.journal, Some(journal)); + assert_eq!(root.max_memory_percent, None); + } + UpperConfig::Ram { max_memory_percent } => { + assert_eq!(root.journal, None); + assert_eq!(root.max_memory_percent, Some(max_memory_percent)); + } + } + } + let decoded = view::parse(actual.as_bytes()).unwrap().unwrap(); + assert_eq!(decoded, config.view); + let old_hostname = cmdline::init_tokens(old.as_bytes()) + .find(|token| matches!(token.name, b"hostname" | b"nvx_hostname")) + .unwrap() + .value + .unwrap(); + assert_eq!(decoded.hostname, old_hostname); + let count = parse_host_mapping_count(actual.as_bytes()).unwrap(); + assert_eq!(count, parse_host_mapping_count(old.as_bytes()).unwrap()); + assert_eq!(count, if filesystem.is_some() { 2 } else { 0 }); + let names: Vec<_> = cmdline::init_tokens(actual.as_bytes()) + .skip(root.map_or(0, |root| root.tokens().len())) + .map(|token| token.name) + .collect(); + let mut expected = vec![b"nvx_hostname".as_slice(), b"nvx_machine_id"]; + if filesystem.is_some() { + expected.push(b"nvx_maps"); + } + assert_eq!(names, expected); + } + } + } +} + +#[test] +fn caller_tokens_follow_rendered_tokens_without_any_rewriting() { + let caller = " \tquiet nvx_entrypoint=/bin/work nvx_arg=\"x y\" nvx_share=0,/work\r\n "; + let mut config = VmConfig { + boot: Some(Boot { + command_line: String::new(), + }), + ..blockless() + }; + let rendered = config.kernel_command_line().unwrap(); + assert!(!rendered.ends_with(' ')); + config.boot.as_mut().unwrap().command_line = caller.to_owned(); + assert_eq!( + config.kernel_command_line().unwrap(), + format!("{rendered} {caller}") + ); +} + +#[test] +fn only_a_checked_cold_boot_has_a_kernel_command_line() { + let config = blockless(); + let error = config.kernel_command_line().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!(error.to_string().contains("Boot"), "{error}"); + let config = VmConfig { + processors: 0, + boot: Some(Boot { + command_line: String::new(), + }), + ..config + }; + let error = config.kernel_command_line().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!(error.to_string().contains("processor"), "{error}"); +} + +#[test] +fn caller_tokens_cannot_replace_rendered_tokens_under_kernel_name_matching() { + let reserved = [ + "nvx_layer", + "nvx_scratch", + "nvx_overlay_upper", + "nvx_overlay_mode", + "nvx_scratch_journal", + "nvx_first_partition_bound", + "nvx_ram_upper_percent", + "nvx_hostname", + "nvx_machine_id", + "nvx_maps", + "virtfs_dir", + "virtfs_tag", + "virtfs_mode", + "virtfs_aggregate", + "virtfs_future_option", + "virtfs_", + ]; + for name in reserved { + let underscores = name.bytes().filter(|&byte| byte == b'_').count(); + for mask in 0..(1 << underscores) { + let mut index = 0; + let spelling: String = name + .chars() + .map(|character| { + if character != '_' { + return character; + } + let dash = mask & (1 << index) != 0; + index += 1; + if dash { '-' } else { '_' } + }) + .collect(); + for command_line in [ + spelling.clone(), + format!("{spelling}=value"), + format!("quiet \"{spelling}=value\""), + format!("quiet\u{a0}{spelling}=value"), + format!("-- {spelling}=value"), + format!("other=\"value {spelling}=value\""), + ] { + for config in [blockless(), layered(erofs())] { + let config = VmConfig { + boot: Some(Boot { + command_line: command_line.clone(), + }), + ..config + }; + for error in [ + config.check().unwrap_err(), + config.kernel_command_line().unwrap_err(), + ] { + assert_eq!(error.kind(), ErrorKind::Unsupported, "{command_line:?}"); + assert!( + error.to_string().contains("Boot.command_line"), + "{command_line:?}: {error}" + ); + } + } + } + } + } +} + +#[test] +fn caller_owned_guest_tokens_and_noncolliding_names_stay_unchanged() { + for command_line in [ + "nvx_entrypoint=/bin/work nvx_arg=\"x y\" nvx_share=0,/work", + "nvx-entrypoint=/bin/work nvx-arg=x nvx-share=0,/work", + "nvx_sandbox=1 nvx_lifecycle=one-shot nvx_workload_account=1", + "quiet tsc=reliable hostname=caller root=/dev/custom", + "nvx_maps_extra=1 NVX_MAPS=1 virtfs=1 virtfsx_name=1 other_virtfs_dir=1", + "other=nvx_maps=1 -- nvx_arg=nvx_hostname", + ] { + let mut config = VmConfig { + boot: Some(Boot { + command_line: String::new(), + }), + ..blockless() + }; + let rendered = config.kernel_command_line().unwrap(); + config.boot.as_mut().unwrap().command_line = command_line.to_owned(); + config.check().unwrap(); + assert_eq!( + config.kernel_command_line().unwrap(), + format!("{rendered} {command_line}") + ); + } +} + +#[test] +fn mapping_table_is_the_exact_export_projection_counted_by_the_boot_token() { + let mut plan = export("/work"); + plan.children.push(ExportChild { + root: missing("writable-export"), + writable: true, + ..plan.children[0].clone() + }); + plan.binds.push(Bind { + child: 1, + source: String::new(), + target: "/zother".to_owned(), + read_only: false, + identity: None, + }); + plan.binds.push(Bind { + source: "nested/file".to_owned(), + target: "/work/nested/file".to_owned(), + ..plan.binds[0].clone() + }); + let mut config = VmConfig { + filesystem: Some(plan.clone()), + boot: Some(Boot { + command_line: String::new(), + }), + ..blockless() + }; + let table = config.mapping_table(); + assert_eq!(table, plan.guest_table()); + assert_eq!( + table, + vec![ + crate::protocol::maps::HostMap { + source: "0".to_owned(), + target: "/work".to_owned(), + read_only: true, + }, + crate::protocol::maps::HostMap { + source: "1".to_owned(), + target: "/zother".to_owned(), + read_only: false, + }, + crate::protocol::maps::HostMap { + source: "0/nested/file".to_owned(), + target: "/work/nested/file".to_owned(), + read_only: true, + }, + ] + ); + let command_line = config.kernel_command_line().unwrap(); + assert_eq!( + parse_host_mapping_count(command_line.as_bytes()).unwrap(), + u32::try_from(table.len()).unwrap() + ); + config.processors = 0; + assert!(config.check().is_err()); + assert_eq!(config.mapping_table(), table); + config.processors = 1; + config.filesystem = None; + assert!(config.mapping_table().is_empty()); + let command_line = config.kernel_command_line().unwrap(); + assert!(!cmdline::init_tokens(command_line.as_bytes()).any(|token| token.name == b"nvx_maps")); +} + +#[test] +fn public_maps_codec_keeps_the_protocol_root_reexports_usable() { + let table: Vec = export("/work").guest_table(); + let payloads = crate::protocol::maps::encode_maps_payloads(&table, 1024).unwrap(); + let decoded: crate::protocol::MapsPayload = + crate::protocol::maps::decode_maps_payload(&payloads[0]).unwrap(); + assert_eq!(decoded.maps, table); + let payloads = crate::protocol::encode_maps_payloads(&decoded.maps, 1024).unwrap(); + let decoded: crate::protocol::maps::MapsPayload = + crate::protocol::decode_maps_payload(&payloads[0]).unwrap(); + assert_eq!(decoded.maps, table); +} + +fn runtime() -> Runtime { + let artifact = ArtifactIdentity::new("fixture").unwrap(); + Runtime { + openvmm: missing("openvmm"), + kernel: missing("vmlinux"), + initramfs: missing("initramfs.cpio.gz"), + identity: RuntimeIdentity::new(artifact.clone(), artifact.clone(), artifact), + hypervisor: Hypervisor::Kvm, + cpu_profile: None, + windows_breakaway: false, + options: LaunchOptions::default(), + } +} + +fn cold(config: VmConfig) -> VmConfig { + VmConfig { + boot: Some(Boot { + command_line: "quiet nvx_entrypoint=/bin/work nvx_arg=ab".to_owned(), + }), + ..config + } +} + +fn values<'a>(arguments: &'a [OsString], option: &str) -> Vec<&'a OsStr> { + arguments + .windows(2) + .filter(|pair| pair[0] == option) + .map(|pair| pair[1].as_os_str()) + .collect() +} + +#[test] +fn cold_boot_command_lines_refuse_native_control_console_syntax() { + let runtime = runtime(); + for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { + for (caller, refused) in [ + (r#"nvx_arg="a b""#, true), + ("note=\"", true), + ("--", true), + ("quiet -- init", true), + ("quiet\t--\r\ninit", true), + ("nvx_arg='a'", false), + ("nvx_arg=--", false), + ("--not-a-delimiter", false), + ("--=1", false), + ("quiet\t--not-a-delimiter\nnote=ok", false), + ] { + let config = VmConfig { + boot: Some(Boot { + command_line: caller.to_owned(), + }), + console, + ..blockless() + }; + config.check().unwrap(); + let command_line = config.kernel_command_line().unwrap(); + assert!(command_line.ends_with(caller)); + let rendered = config.openvmm_arguments( + &runtime, + "/missing/control", + Path::new("/missing/report"), + (console == ConsoleAttachment::Listener).then_some("/missing/console"), + ); + if refused { + for result in [config.check_command_line(), rendered.map(|_| ())] { + let error = result.unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!( + error.to_string().contains("control console"), + "{caller:?}: {error}" + ); + } + } else { + config.check_command_line().unwrap(); + assert_eq!( + values(&rendered.unwrap(), "--cmdline"), + [OsStr::new(&command_line)] + ); + } + } + } +} + +#[test] +fn native_argument_listener_names_are_distinct() { + let config = cold(blockless()); + let endpoint = r"\\.\pipe\nvx-console"; + for hypervisor in [Hypervisor::Kvm, Hypervisor::Mshv, Hypervisor::Whp] { + let runtime = Runtime { + hypervisor, + ..runtime() + }; + for console_endpoint in [endpoint, r"\\.\PIPE\NVX-CONSOLE", r"\\.\pipe\nvx-boot"] { + let rendered = config.openvmm_arguments( + &runtime, + endpoint, + Path::new("/missing/report"), + Some(console_endpoint), + ); + if console_endpoint == endpoint + || (hypervisor == Hypervisor::Whp + && console_endpoint.eq_ignore_ascii_case(endpoint)) + { + let error = rendered.unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!(error.to_string().contains("distinct endpoints")); + } else { + let arguments = rendered.unwrap(); + assert_eq!( + values(&arguments, "--virtio-console"), + [OsStr::new(&format!("listen={console_endpoint}"))] + ); + } + } + let disconnected = VmConfig { + console: ConsoleAttachment::Disconnected, + ..config.clone() + }; + let arguments = disconnected + .openvmm_arguments(&runtime, endpoint, Path::new("/missing/report"), None) + .unwrap(); + assert_eq!(values(&arguments, "--virtio-console"), [OsStr::new("none")]); + } +} + +#[cfg(any(unix, windows))] +#[test] +fn native_argument_paths_require_unicode() { + #[cfg(unix)] + let invalid = { + use std::os::unix::ffi::OsStringExt; + PathBuf::from(OsString::from_vec(vec![b'i', 0xff])) + }; + #[cfg(windows)] + let invalid = { + use std::os::windows::ffi::OsStringExt; + PathBuf::from(OsString::from_wide(&[105, 0xd800])) + }; + for scratch in [false, true] { + let mut root = erofs(); + if scratch { + root.upper = UpperConfig::Scratch { + path: invalid.clone(), + journal: Journal::Forbidden, + }; + } else { + root.image = invalid.clone(); + } + let config = cold(layered(root)); + config.check().unwrap(); + config.kernel_command_line().unwrap(); + for result in [ + config.check_command_line(), + config + .openvmm_arguments( + &runtime(), + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .map(|_| ()), + ] { + let error = result.unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!(error.to_string().contains("Unicode")); + } + } + + let image = PathBuf::from("image-\u{e9} with [nested[1]].erofs"); + let scratch = PathBuf::from("scratch-\u{f1} with spaces.ext4"); + let config = cold(layered(LayeredRoot { + image: image.clone(), + upper: UpperConfig::Scratch { + path: scratch.clone(), + journal: Journal::Forbidden, + }, + ..erofs() + })); + config.check_command_line().unwrap(); + let arguments = config + .openvmm_arguments( + &runtime(), + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .unwrap(); + assert_eq!( + values(&arguments, "--microvm-sandbox-block"), + [ + OsStr::new(&format!("distro:file:{},ro", image.display())), + OsStr::new(&format!("scratch:file:{}", scratch.display())), + ] + ); +} + +#[test] +fn cold_boot_arguments_select_the_configured_guest_without_managed_tokens() { + for hypervisor in [Hypervisor::Kvm, Hypervisor::Mshv, Hypervisor::Whp] { + for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { + let config = cold(VmConfig { + processors: 4, + memory_mib: u64::from(u32::MAX) + 1, + console, + ..blockless() + }); + let runtime = Runtime { + hypervisor, + ..runtime() + }; + let endpoint = "/missing/control.sock"; + let report = Path::new("/missing/outcome.json"); + let console_endpoint = + (console == ConsoleAttachment::Listener).then_some("/missing/console.sock"); + let arguments = config + .openvmm_arguments(&runtime, endpoint, report, console_endpoint) + .unwrap(); + let console_backend = if console_endpoint.is_some() { + "listen=/missing/console.sock" + } else { + "none" + }; + let expected: Vec = [ + OsStr::new("--machine"), OsStr::new("microvm"), + OsStr::new("--processors"), OsStr::new("4"), + OsStr::new("--single-process"), + OsStr::new("--hypervisor"), OsStr::new(hypervisor.as_str()), + OsStr::new("--memory"), OsStr::new("4294967296M"), + OsStr::new("--kernel"), runtime.kernel.as_os_str(), + OsStr::new("--initrd"), runtime.initramfs.as_os_str(), + OsStr::new("--cmdline"), + OsStr::new("nvx_hostname=626f78 nvx_machine_id=image quiet nvx_entrypoint=/bin/work nvx_arg=ab"), + OsStr::new("--virtio-console"), OsStr::new(console_backend), + OsStr::new("--microvm-control-console"), OsStr::new("listen=/missing/control.sock"), + OsStr::new("--microvm-control-auth-stdin"), + OsStr::new("--microvm-report"), report.as_os_str(), + OsStr::new("--guest-shutdown-action"), OsStr::new("exit"), + ].into_iter().map(OsStr::to_os_string).collect(); + assert_eq!(arguments, expected); + for forbidden in [ + "--microvm-lifecycle", + "--microvm-workload-identity", + "--microvm-sandbox-block", + ] { + assert!(!arguments.iter().any(|argument| argument == forbidden)); + } + } + } +} + +#[test] +fn cold_boot_arguments_lower_layered_roots_networks_and_exports_by_path() { + for format in [ + RootImage::erofs(None), + RootImage::gpt(Some(Uuid::from_bytes([7; 16]))), + ] { + for upper in [ + UpperConfig::Ram { + max_memory_percent: 37, + }, + UpperConfig::Scratch { + path: missing("scratch with spaces.ext4"), + journal: Journal::Forbidden, + }, + ] { + for address in [None, Some(Ipv4Cidr::parse("172.16.8.2/24").unwrap())] { + let config = cold(VmConfig { + root: RootConfig::Layered(LayeredRoot { + image: missing("root with spaces.img"), + format, + gpt_first_partition_bound: (format.format == ImageFormat::Gpt) + .then_some(65536), + upper: upper.clone(), + overlay: OverlayMode::UntrustedLowers, + }), + network: Some(NetworkConfig { + guest_network: address, + policy: NetworkPolicy::new(Access::Deny) + .with_allow(EgressRule::to("192.0.2.0/24").on_port(Protocol::Tcp, 443)), + }), + filesystem: Some(export("/work")), + ..blockless() + }); + let arguments = config + .openvmm_arguments( + &runtime(), + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .unwrap(); + let mut image = OsString::from("distro:file:"); + image.push(missing("root with spaces.img")); + image.push(",ro"); + let mut blocks = vec![image]; + if let UpperConfig::Scratch { path, .. } = &upper { + let mut scratch = OsString::from("scratch:file:"); + scratch.push(path); + blocks.push(scratch); + } + assert_eq!( + values(&arguments, "--microvm-sandbox-block"), + blocks.iter().map(OsString::as_os_str).collect::>() + ); + assert_eq!( + values(&arguments, "--cmdline"), + [OsStr::new(&config.kernel_command_line().unwrap())] + ); + assert_eq!( + values(&arguments, "--net"), + [OsStr::new(if address.is_some() { + "172.16.8.2/24" + } else { + DEFAULT_GUEST_NETWORK + })] + ); + assert_eq!( + values(&arguments, "--network-profile"), + [OsStr::new("portable")] + ); + assert_eq!(values(&arguments, "--network-egress"), [OsStr::new("deny")]); + assert_eq!( + values(&arguments, "--network-egress-allow"), + [OsStr::new("192.0.2.0/24:tcp:443")] + ); + assert_eq!( + values(&arguments, "--mount-aggregate"), + [OsStr::new(crate::host::filesystem::GUEST_EXPORT)] + ); + let mut child = OsString::from("0,"); + child.push(missing("export")); + child.push(",ro"); + assert_eq!(values(&arguments, "--mount-child"), [child.as_os_str()]); + for forbidden in ["--microvm-lifecycle", "--microvm-workload-identity"] { + assert!(!arguments.iter().any(|argument| argument == forbidden)); + } + } + } + } +} + +#[test] +fn cold_boot_arguments_refuse_disk_syntax_in_image_and_scratch_paths() { + let runtime = runtime(); + for name in [ + "disk;direct", + "disk;create=1M", + "disk,ro", + "disk[", + "disk]", + "disk][", + ] { + for scratch in [false, true] { + let mut root = erofs(); + if scratch { + root.upper = UpperConfig::Scratch { + path: missing(name), + journal: Journal::Forbidden, + }; + } else { + root.image = missing(name); + } + let config = cold(layered(root)); + for result in [ + config.check_command_line(), + config + .openvmm_arguments( + &runtime, + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .map(|_| ()), + ] { + let error = result.unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!( + error + .to_string() + .contains("commas, semicolons, or unbalanced brackets"), + "{name}: {error}" + ); + } + } + } + + let image = missing("image with spaces[1].erofs"); + let scratch = missing("scratch with [nested[1]] brackets.ext4"); + let config = cold(layered(LayeredRoot { + image: image.clone(), + upper: UpperConfig::Scratch { + path: scratch.clone(), + journal: Journal::Forbidden, + }, + ..erofs() + })); + config.check_command_line().unwrap(); + let arguments = config + .openvmm_arguments( + &runtime, + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .unwrap(); + let blocks: Vec = [ + format!("distro:file:{},ro", image.display()), + format!("scratch:file:{}", scratch.display()), + ] + .map(OsString::from) + .into(); + assert_eq!( + values(&arguments, "--microvm-sandbox-block"), + blocks.iter().map(OsString::as_os_str).collect::>() + ); +} + +#[test] +fn an_explicit_deny_all_network_keeps_its_nic() { + let mut config = cold(blockless()); + config.network = Some(NetworkConfig { + guest_network: None, + policy: NetworkPolicy::new(Access::Deny), + }); + let arguments = config + .openvmm_arguments( + &runtime(), + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .unwrap(); + assert_eq!( + values(&arguments, "--net"), + [OsStr::new(DEFAULT_GUEST_NETWORK)] + ); + for option in ["--network-egress", "--network-ingress", "--host-loopback"] { + assert_eq!(values(&arguments, option), [OsStr::new("deny")]); + } +} + +#[test] +fn cold_boot_arguments_render_every_launch_option_without_process_side_effects() { + for (action, text) in [ + (GuestPowerAction::Exit, "exit"), + (GuestPowerAction::Halt, "halt"), + (GuestPowerAction::Reset, "reset"), + ] { + for timeout in [ + None, + Some(Duration::from_millis(1)), + Some(Duration::from_millis(59999)), + Some(Duration::from_millis(60000)), + ] { + for profile in [None, Some("auto".to_owned())] { + let mut runtime = Runtime { + cpu_profile: profile.clone(), + options: LaunchOptions { + guest_shutdown: action, + control_auth_timeout: timeout, + }, + ..runtime() + }; + let config = cold(blockless()); + let render = |runtime: &Runtime| { + config + .openvmm_arguments( + runtime, + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .unwrap() + }; + let arguments = render(&runtime); + assert_eq!( + values(&arguments, "--guest-shutdown-action"), + [OsStr::new(text)] + ); + assert_eq!( + values(&arguments, "--cpu-profile"), + profile + .as_deref() + .map(OsStr::new) + .into_iter() + .collect::>() + ); + let milliseconds = timeout.map(|value| value.as_millis().to_string()); + assert_eq!( + values(&arguments, "--microvm-control-auth-timeout-ms"), + milliseconds + .as_deref() + .map(OsStr::new) + .into_iter() + .collect::>() + ); + runtime.windows_breakaway = true; + assert_eq!(arguments, render(&runtime)); + } + } + } +} + +#[test] +fn authentication_timeout_refuses_fractional_and_out_of_range_milliseconds() { + for timeout in [ + Duration::ZERO, + Duration::from_nanos(1), + Duration::from_micros(999), + Duration::from_nanos(1_000_001), + Duration::from_nanos(60_000_000_001), + Duration::from_millis(60001), + Duration::from_secs(u64::MAX), + ] { + let runtime = Runtime { + options: LaunchOptions { + control_auth_timeout: Some(timeout), + ..LaunchOptions::default() + }, + ..runtime() + }; + let error = cold(blockless()) + .openvmm_arguments( + &runtime, + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!( + error.to_string().contains("control_auth_timeout"), + "{error}" + ); + } +} + +#[test] +fn required_output_names_and_cold_boot_inputs_are_refused_without_host_io() { + let config = cold(blockless()); + let runtime = runtime(); + for (endpoint, report, console) in [ + ("", Path::new("/missing/report"), Some("/missing/console")), + ( + "/missing/control\0", + Path::new("/missing/report"), + Some("/missing/console"), + ), + ("/missing/control", Path::new(""), Some("/missing/console")), + ("/missing/control", Path::new("/missing/report"), None), + ("/missing/control", Path::new("/missing/report"), Some("")), + ( + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console\0"), + ), + ] { + assert_eq!( + config + .openvmm_arguments(&runtime, endpoint, report, console) + .unwrap_err() + .kind(), + ErrorKind::Unsupported + ); + } + assert_eq!( + blockless() + .openvmm_arguments( + &runtime, + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console") + ) + .unwrap_err() + .kind(), + ErrorKind::Unsupported + ); + assert_eq!( + blockless().check_command_line().unwrap_err().kind(), + ErrorKind::Unsupported + ); + let disconnected = cold(VmConfig { + console: ConsoleAttachment::Disconnected, + ..blockless() + }); + assert!( + disconnected + .openvmm_arguments( + &runtime, + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console") + ) + .is_err() + ); + let invalid = cold(VmConfig { + processors: 0, + ..blockless() + }); + assert!( + invalid + .openvmm_arguments( + &runtime, + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console") + ) + .is_err() + ); + assert!(invalid.check_command_line().is_err()); +} + +#[test] +fn configuration_command_lines_keep_the_same_reserve_on_every_host() { + let hidden = |padding| { + let mut plan = export("/work"); + let root = if cfg!(windows) { + r"D:\host\work" + } else { + "/host/work" + }; + plan.children[0].root = PathBuf::from(root).join("r".repeat(padding)); + plan.children[0].denied = (0..128) + .map(|index| { + plan.children[0] + .root + .join(format!("{index:03}-{}", "x".repeat(120))) + }) + .collect(); + plan.children[0].denied_identities = vec![None; 128]; + cold(VmConfig { + filesystem: Some(plan), + ..blockless() + }) + }; + hidden(80).check_command_line().unwrap(); + let reserved = hidden(95); + let error = reserved.check_command_line().unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + assert!( + error.to_string().contains("2048 held in reserve"), + "{error}" + ); + reserved + .openvmm_arguments( + &runtime(), + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .unwrap(); + let oversized = hidden(110); + assert_eq!( + oversized.check_command_line().unwrap_err().kind(), + ErrorKind::Unsupported + ); + assert_eq!( + oversized + .openvmm_arguments( + &runtime(), + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .unwrap_err() + .kind(), + ErrorKind::Unsupported + ); + let config = cold(blockless()); + config.check_command_line().unwrap(); + let runtime = Runtime { + openvmm: PathBuf::from("x".repeat(PROCESS_COMMAND_LINE_LIMIT)), + ..runtime() + }; + assert!( + config + .openvmm_arguments( + &runtime, + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .is_err() + ); +} + +#[test] +fn process_command_lines_are_measured_as_windows_quotes_them() { + let cases: [(&str, &[&str], &str); 8] = [ + ( + r"C:\Program Files\openvmm.exe", + &["--a", "b c", ""], + r#""C:\Program Files\openvmm.exe" --a "b c" """#, + ), + ("x", &[r#"say "hi""#], r#""x" "say \"hi\"""#), + ("x", &[r#"a"b"#], r#""x" a\"b"#), + ("x", &[r"dir\ with\"], r#""x" "dir\ with\\""#), + ("x", &[r"a\\b", "é"], r#""x" a\\b é"#), + ("", &[""], r#""" """#), + ("x", &["a\tb"], "\"x\" \"a\tb\""), + ("x", &["\u{1f680}"], "\"x\" \u{1f680}"), + ]; + for (program, arguments, line) in cases { + let arguments: Vec = arguments.iter().map(OsString::from).collect(); + assert_eq!( + process_command_line_units(OsStr::new(program), &arguments), + line.encode_utf16().count() + 1, + "{line}" + ); + } +} + +#[test] +fn a_process_command_line_fits_while_it_leaves_its_reserve() { + // Any program and arguments, such as an adapter's: a command line of the limit fits, + // one more code unit does not, and a reserve takes its part of the limit. + let arguments: Vec = ["--a", "b c"].map(OsString::from).into(); + let base = process_command_line_units(OsStr::new("/o"), &arguments); + let program = |extra: usize| OsString::from(format!("/o{}", "o".repeat(extra))); + let fitting = program(PROCESS_COMMAND_LINE_LIMIT - base); + assert_eq!( + process_command_line_units(&fitting, &arguments), + PROCESS_COMMAND_LINE_LIMIT + ); + fit_process_command_line(&fitting, &arguments, 0).unwrap(); + let held = fit_process_command_line(&fitting, &arguments, 1).unwrap_err(); + assert_eq!(held.kind(), ErrorKind::Unsupported); + assert!( + held.to_string() + .contains("would take 32767 of the 32767 characters"), + "{held}" + ); + assert!( + held.to_string().contains("with 1 held in reserve"), + "{held}" + ); + let longer = program(PROCESS_COMMAND_LINE_LIMIT - base + 1); + let error = fit_process_command_line(&longer, &arguments, 0).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported); + // A reserve past the limit leaves no room for any command line. + assert!(fit_process_command_line(OsStr::new("o"), &[], usize::MAX).is_err()); + // The arguments alone, which a plan renders, take what they take on the command line. + let alone = process_command_line_units(OsStr::new(""), &arguments) + - process_command_line_units(OsStr::new(""), &[]); + let reserve = PROCESS_COMMAND_LINE_LIMIT - alone; + fit_arguments(&arguments, reserve).unwrap(); + let held = fit_arguments(&arguments, reserve + 1).unwrap_err(); + assert!( + held.to_string() + .contains("held in reserve for the rest of the launch"), + "{held}" + ); +} diff --git a/nvx-rs/src/host/launch.rs b/nvx-rs/src/host/launch.rs index 713fa1428..16e6c533c 100644 --- a/nvx-rs/src/host/launch.rs +++ b/nvx-rs/src/host/launch.rs @@ -16,11 +16,12 @@ //! [`PreparedLaunch::adapter_arguments`](super::sandbox::PreparedLaunch::adapter_arguments) holds //! an adapter's command line to the limit too. -use std::ffi::{OsStr, OsString}; +use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::time::Duration; use super::artifacts::Artifacts; +use super::config::{self, COMMAND_LINE_BUDGET}; use super::filesystem::ExportPlan; use super::network::{self, NetworkPolicy}; use crate::error::{Error, Result}; @@ -29,14 +30,11 @@ use crate::net::{is_valid_guest_network, is_valid_hostname}; use crate::protocol::WORKLOAD_ACCOUNT_TOKEN; use crate::protocol::cmdline::{self, Parameter}; -pub use super::config::DEFAULT_GUEST_NETWORK; - -/// Bytes of the x86 kernel command line available to caller tokens, including the NUL. -pub const COMMAND_LINE_BUDGET: usize = 1024; -/// Longest Windows process command line, in UTF-16 code units, including the terminating NUL. -/// -/// OpenVMM's command line is held to it on every host, so a policy behaves alike on all of them. -pub const PROCESS_COMMAND_LINE_LIMIT: usize = 32_767; +pub(crate) use super::config::fit_arguments; +pub use super::config::{ + DEFAULT_GUEST_NETWORK, PROCESS_COMMAND_LINE_LIMIT, fit_process_command_line, + process_command_line_units, +}; /// Part of that limit kept for what may change between provision and start, such as the /// configured OpenVMM and guest paths. const PROCESS_COMMAND_LINE_RESERVE: usize = 1024; @@ -349,7 +347,7 @@ impl VmmLaunch<'_> { /// plan: a quoted final path of up to `MAX_PATH` behind `\\?\` in place of a path of at least /// 4. A launch whose inputs are longer may still not fit, which its complete check refuses /// before OpenVMM starts. - pub const RESERVE: usize = 2048; + pub const RESERVE: usize = config::OPENVMM_COMMAND_LINE_RESERVE; /// Returns the arguments that name the launch's inputs, with the kernel command line /// `kernel_command_line`: the VM in one process, `--single-process`, on the hypervisor, @@ -360,30 +358,19 @@ impl VmmLaunch<'_> { /// OpenVMM reads from its standard input, `--microvm-control-auth-stdin`, and the outcome /// report, `--microvm-report`. pub fn arguments(&self, kernel_command_line: &str) -> Vec { - let memory = format!("{}M", self.memory_mib); - let control = format!("listen={}", self.endpoint); - [ - OsStr::new("--single-process"), - OsStr::new("--hypervisor"), - OsStr::new(self.hypervisor.as_str()), - OsStr::new("--memory"), - OsStr::new(&memory), - OsStr::new("--kernel"), - self.kernel.as_os_str(), - OsStr::new("--initrd"), - self.initrd.as_os_str(), - OsStr::new("--cmdline"), - OsStr::new(kernel_command_line), - OsStr::new("--virtio-console"), - OsStr::new("none"), - OsStr::new("--microvm-control-console"), - OsStr::new(&control), - OsStr::new("--microvm-control-auth-stdin"), - OsStr::new("--microvm-report"), - self.report.as_os_str(), - ] - .map(OsStr::to_os_string) - .into() + let mut arguments = config::boot_arguments( + self.hypervisor, + u64::from(self.memory_mib), + self.kernel, + self.initrd, + kernel_command_line, + ); + arguments.extend(config::console_arguments( + "none", + self.endpoint, + self.report, + )); + arguments } } @@ -475,98 +462,5 @@ pub fn timeout_problem(value: Duration, positive: bool) -> Option { }) } -/// Returns the length in UTF-16 code units, including the terminating NUL, of the Windows command -/// line that runs `program` with `arguments`, quoted the way the Windows platform quotes it: the -/// program always in quotes, and each argument in quotes if it is empty or holds a space or a -/// tab, with a backslash before each quote that it holds and a backslash before each backslash -/// that precedes a quote, its closing quote included, and one space between them. -/// -/// It is the length that [`PROCESS_COMMAND_LINE_LIMIT`] bounds, whatever the host: -/// [`fit_process_command_line`] checks it. -pub fn process_command_line_units(program: &OsStr, arguments: &[OsString]) -> usize { - argument_units(program, true) + arguments_units(arguments) -} - -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) unless the Windows command -/// line that runs `program`, such as OpenVMM, with `arguments`, as [`process_command_line_units`] -/// counts it, leaves `reserve` code units of [`PROCESS_COMMAND_LINE_LIMIT`] unused: none for the -/// complete command line of a launch about to spawn OpenVMM, and some for what may change before -/// then, such as the paths of the artifacts. -/// -/// The error says how long the command line is, and how to shorten it. -pub fn fit_process_command_line( - program: &OsStr, - arguments: &[OsString], - reserve: usize, -) -> Result<()> { - let length = process_command_line_units(program, arguments); - let held = if reserve == 0 { - String::new() - } else { - format!(", with {reserve} held in reserve") - }; - fit("OpenVMM's command line", length, reserve, &held) -} - -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) unless OpenVMM's -/// `arguments`, which a plan renders, without the program and the rest of the launch, leave -/// `reserve` code units of [`PROCESS_COMMAND_LINE_LIMIT`] to that rest. -pub(crate) fn fit_arguments(arguments: &[OsString], reserve: usize) -> Result<()> { - let held = format!(", with {reserve} held in reserve for the rest of the launch"); - fit( - "OpenVMM's arguments", - arguments_units(arguments), - reserve, - &held, - ) -} - -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported), saying that `subject` -/// would take `length` code units, `held` describing `reserve`, unless they leave `reserve` of -/// [`PROCESS_COMMAND_LINE_LIMIT`] unused. -fn fit(subject: &str, length: usize, reserve: usize, held: &str) -> Result<()> { - if length.saturating_add(reserve) <= PROCESS_COMMAND_LINE_LIMIT { - return Ok(()); - } - Err(Error::unsupported(format!( - "{subject} would take {length} of the {PROCESS_COMMAND_LINE_LIMIT} characters that a \ - Windows process command line allows{held}; map, hide, or allow fewer or shorter \ - filesystem paths, or use fewer network rules" - ))) -} - -/// Returns the length in UTF-16 code units of `arguments` on a Windows command line, quoted as -/// [`process_command_line_units`] quotes them, each with the space before it. -fn arguments_units(arguments: &[OsString]) -> usize { - arguments - .iter() - .map(|argument| argument_units(argument, false)) - .sum() -} - -/// Returns the length in UTF-16 code units of `argument` on a Windows command line, quoted as -/// [`process_command_line_units`] quotes it, always if it is the `program`, with one separating -/// space, or the terminating NUL after the last argument. -fn argument_units(argument: &OsStr, program: bool) -> usize { - let text = argument.to_string_lossy(); - let quoted = program || text.is_empty() || text.contains([' ', '\t']); - let mut length = 1 + text.encode_utf16().count() + if quoted { 2 } else { 0 }; - let mut backslashes = 0; - for character in text.chars() { - match character { - '\\' => backslashes += 1, - '"' => { - length += backslashes + 1; - backslashes = 0; - } - _ => backslashes = 0, - } - } - if quoted { - length += backslashes; - } - length -} - #[cfg(test)] mod tests; diff --git a/nvx-rs/src/host/launch/tests.rs b/nvx-rs/src/host/launch/tests.rs index 371817c6f..2cc5fa0ca 100644 --- a/nvx-rs/src/host/launch/tests.rs +++ b/nvx-rs/src/host/launch/tests.rs @@ -2,6 +2,7 @@ use proptest::prelude::*; use super::*; use crate::ErrorKind; +use crate::host::config::arguments_units; use crate::host::filesystem::{Bind, ExportChild, Owner}; use crate::protocol::cmdline::tests::model; @@ -114,29 +115,6 @@ fn mappings_add_the_export_and_the_mapping_count() { ); } -#[test] -fn process_command_lines_are_measured_as_windows_quotes_them() { - let cases: [(&str, &[&str], &str); 5] = [ - ( - r"C:\Program Files\openvmm.exe", - &["--a", "b c", ""], - r#""C:\Program Files\openvmm.exe" --a "b c" """#, - ), - ("x", &[r#"say "hi""#], r#""x" "say \"hi\"""#), - ("x", &[r#"a"b"#], r#""x" a\"b"#), - ("x", &[r"dir\ with\"], r#""x" "dir\ with\\""#), - ("x", &[r"a\\b", "é"], r#""x" a\\b é"#), - ]; - for (program, arguments, line) in cases { - let arguments: Vec = arguments.iter().map(OsString::from).collect(); - assert_eq!( - process_command_line_units(OsStr::new(program), &arguments), - line.encode_utf16().count() + 1, - "{line}" - ); - } -} - #[test] fn oversized_process_command_lines_are_rejected() { let mut spec = spec(); @@ -200,48 +178,6 @@ fn launches_may_use_the_reserve_that_provisioning_keeps() { ); } -#[test] -fn a_process_command_line_fits_while_it_leaves_its_reserve() { - // Any program and arguments, such as an adapter's: a command line of the limit fits, - // one more code unit does not, and a reserve takes its part of the limit. - let arguments: Vec = ["--a", "b c"].map(OsString::from).into(); - let base = process_command_line_units(OsStr::new("/o"), &arguments); - let program = |extra: usize| OsString::from(format!("/o{}", "o".repeat(extra))); - let fitting = program(PROCESS_COMMAND_LINE_LIMIT - base); - assert_eq!( - process_command_line_units(&fitting, &arguments), - PROCESS_COMMAND_LINE_LIMIT - ); - fit_process_command_line(&fitting, &arguments, 0).unwrap(); - let held = fit_process_command_line(&fitting, &arguments, 1).unwrap_err(); - assert_eq!(held.kind(), ErrorKind::Unsupported); - assert!( - held.to_string() - .contains("would take 32767 of the 32767 characters"), - "{held}" - ); - assert!( - held.to_string().contains("with 1 held in reserve"), - "{held}" - ); - let longer = program(PROCESS_COMMAND_LINE_LIMIT - base + 1); - let error = fit_process_command_line(&longer, &arguments, 0).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - // A reserve past the limit leaves no room for any command line. - assert!(fit_process_command_line(OsStr::new("o"), &[], usize::MAX).is_err()); - // The arguments alone, which a plan renders, take what they take on the command line. - let alone = process_command_line_units(OsStr::new(""), &arguments) - - process_command_line_units(OsStr::new(""), &[]); - let reserve = PROCESS_COMMAND_LINE_LIMIT - alone; - fit_arguments(&arguments, reserve).unwrap(); - let held = fit_arguments(&arguments, reserve + 1).unwrap_err(); - assert!( - held.to_string() - .contains("held in reserve for the rest of the launch"), - "{held}" - ); -} - #[test] fn every_launch_names_its_own_inputs_alike() { let launch = VmmLaunch { diff --git a/nvx-rs/src/host/mod.rs b/nvx-rs/src/host/mod.rs index 6594e7cd6..d103cb1a8 100644 --- a/nvx-rs/src/host/mod.rs +++ b/nvx-rs/src/host/mod.rs @@ -50,7 +50,9 @@ //! [`PreparedLaunch`](sandbox::PreparedLaunch) hands the VMM the files that those checks //! opened, which it inherits through the [`supervisor`]'s allowlist. //! - [`vm`] holds a caller-owned VM directory, private to the current user, the fixed names of -//! its files and endpoints, and its control credential, which the library never writes. +//! its files and endpoints, and a checked cold [`Prepared`](vm::Prepared) launch with its +//! opened log and control credential, which the library never writes. [`snapshot`] names the +//! opaque input of a future restore; cold creation refuses it. //! - `cli` (feature `cli`) is the `nvx-rs` command-line interface, which runs microVMs and //! sandboxes, looks up and downloads artifacts, and compiles egress policies. //! @@ -88,6 +90,7 @@ pub mod runtime; pub mod sandbox; pub mod seal; pub mod session; +pub mod snapshot; mod spawn; pub mod store; pub mod supervisor; diff --git a/nvx-rs/src/host/platform/windows/spawn.rs b/nvx-rs/src/host/platform/windows/spawn.rs index 25c40aa3a..c06723ae1 100644 --- a/nvx-rs/src/host/platform/windows/spawn.rs +++ b/nvx-rs/src/host/platform/windows/spawn.rs @@ -11,26 +11,27 @@ use std::{mem, ptr}; #[cfg(feature = "cli")] use windows_sys::Win32::Foundation::{ - DUPLICATE_SAME_ACCESS, DuplicateHandle, INVALID_HANDLE_VALUE, WAIT_TIMEOUT, + DUPLICATE_SAME_ACCESS, DuplicateHandle, INVALID_HANDLE_VALUE, }; use windows_sys::Win32::Foundation::{ GetHandleInformation, HANDLE, HANDLE_FLAG_INHERIT, SetHandleInformation, WAIT_OBJECT_0, + WAIT_TIMEOUT, }; #[cfg(feature = "cli")] use windows_sys::Win32::System::Console::{ GetStdHandle, STD_ERROR_HANDLE, STD_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, }; +#[cfg(feature = "cli")] +use windows_sys::Win32::System::Threading::GetCurrentProcess; use windows_sys::Win32::System::Threading::{ CREATE_BREAKAWAY_FROM_JOB, CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, CreateProcessW, DeleteProcThreadAttributeList, - EXTENDED_STARTUPINFO_PRESENT, INFINITE, InitializeProcThreadAttributeList, + EXTENDED_STARTUPINFO_PRESENT, GetExitCodeProcess, INFINITE, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_JOB_LIST, PROCESS_CREATION_FLAGS, PROCESS_INFORMATION, STARTF_USESTDHANDLES, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute, WaitForSingleObject, }; -#[cfg(feature = "cli")] -use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetExitCodeProcess}; #[cfg(feature = "cli")] use super::process::refused_termination; @@ -78,7 +79,6 @@ impl LaunchedProcess { /// Waits up to `timeout` for the process to exit, and returns its exit status if it has, /// as `Popen.wait` reads it with `GetExitCodeProcess`. - #[cfg(feature = "cli")] pub(crate) fn wait_timeout(&self, timeout: Duration) -> io::Result> { let milliseconds = u32::try_from(timeout.as_millis()).unwrap_or(INFINITE - 1); // SAFETY: CreateProcessW returned the handle with full access. diff --git a/nvx-rs/src/host/runtime/mod.rs b/nvx-rs/src/host/runtime/mod.rs index 90ba8edd2..9310b0342 100644 --- a/nvx-rs/src/host/runtime/mod.rs +++ b/nvx-rs/src/host/runtime/mod.rs @@ -47,22 +47,24 @@ pub struct Runtime { /// attaches its own. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct LaunchOptions { - /// What OpenVMM does when the guest powers off. + /// What OpenVMM does on a plain guest power-off or hibernate event. A guest power-off that + /// reports an exit status always exits OpenVMM with that status, regardless of this action. pub guest_shutdown: GuestPowerAction, /// How long OpenVMM waits for the host to authenticate on the control console, or `None` for - /// OpenVMM's default. + /// OpenVMM's default. The cold-boot renderer accepts whole milliseconds from 1 to 60000. pub control_auth_timeout: Option, } -/// What OpenVMM does when the guest powers off. +/// What OpenVMM does on a plain guest power-off or hibernate event. A power-off with an exit +/// status always exits OpenVMM with that status instead. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub enum GuestPowerAction { - /// OpenVMM exits, which ends the VM: the default, on which stopping a VM relies. + /// OpenVMM exits, which ends the VM: the default. #[default] Exit, - /// OpenVMM halts the VM and keeps running. + /// OpenVMM halts the VM and keeps running, unless the power-off reports an exit status. Halt, - /// OpenVMM restarts the guest. + /// OpenVMM restarts the guest, unless the power-off reports an exit status. Reset, } diff --git a/nvx-rs/src/host/sandbox/mod.rs b/nvx-rs/src/host/sandbox/mod.rs index c602b6813..cfa0f54e9 100644 --- a/nvx-rs/src/host/sandbox/mod.rs +++ b/nvx-rs/src/host/sandbox/mod.rs @@ -75,6 +75,7 @@ pub use self::plan::{ pub use self::prepared::PreparedLaunch; use crate::error::{Error, ErrorCode, Result}; use crate::host::artifacts::require_existing; +pub(crate) use crate::host::config::reject_disk_path; use crate::host::filesystem::ExportPlan; use crate::net::is_valid_hostname; use crate::protocol::cmdline; @@ -162,27 +163,6 @@ pub fn require_file(path: &Path, description: &str) -> Result<()> { require_existing(path, description, ErrorCode::Unavailable) } -/// Refuses a disk path that OpenVMM's `--microvm-sandbox-block` value could not hold: one with -/// a comma, semicolon, or improperly nested brackets. The message shows `path` as `shown`. -pub(crate) fn reject_disk_path(path: &Path, shown: &str) -> Result<()> { - let depth = path - .to_string_lossy() - .bytes() - .try_fold(0_usize, |depth, byte| match byte { - b'[' => Some(depth + 1), - b']' => depth.checked_sub(1), - b',' | b';' => None, - _ => Some(depth), - }); - if depth != Some(0) { - return Err(refused(format!( - "disk paths containing commas, semicolons, or unbalanced brackets are unsupported: \ - {shown}" - ))); - } - Ok(()) -} - /// The extensions of the files that OpenVMM does not attach as the raw image of a sandbox block /// device: it opens a `.vhd`, `.vhdx`, or `.vmgs` file in its container format, refuses to write /// an `.iso` file, and refuses all four for the block devices of a snapshot. 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/snapshot.rs b/nvx-rs/src/host/snapshot.rs new file mode 100644 index 000000000..ee79b62bb --- /dev/null +++ b/nvx-rs/src/host/snapshot.rs @@ -0,0 +1,8 @@ +//! A snapshot from which a VM may later be restored. + +/// An opaque snapshot input. Creating a VM from a snapshot is not supported yet. +#[derive(Debug)] +pub struct Snapshot { + /// Reserved for snapshot metadata. + pub(crate) _private: (), +} diff --git a/nvx-rs/src/host/spawn.rs b/nvx-rs/src/host/spawn.rs index e6bd2f2d7..d1b9ebcc3 100644 --- a/nvx-rs/src/host/spawn.rs +++ b/nvx-rs/src/host/spawn.rs @@ -10,7 +10,6 @@ use std::path::PathBuf; use std::process::{Child, Command, Stdio}; #[cfg(not(windows))] use std::thread; -#[cfg(feature = "cli")] use std::time::Duration; #[cfg(not(windows))] use std::time::Instant; @@ -146,8 +145,9 @@ pub fn spawn( } /// A VMM that [`spawn`] started, which the caller has not released yet: on Unix, its child -/// process, which the caller has not reaped, so that its ID names no other process; on Windows, -/// its process handle. +/// process; on Windows, its process handle. On Unix the child's ID names no other process until +/// [`wait_timeout`](Self::wait_timeout) reaps it; hold it with [`process`](Self::process) before +/// waiting to keep a verified handle after it exits. /// /// Record it with [`Launch::record_process`](crate::host::store::Launch::record_process), or hold /// it with [`process`](Self::process). Then release it with [`detach`](Self::detach), or end it @@ -264,8 +264,10 @@ impl Launched { /// Waits up to `timeout` for the process to exit, and returns its status if it has, as /// `Popen.wait` and `Popen.poll` report it: the exit code, or on Unix the negated number of /// the signal that ended the process. - #[cfg(feature = "cli")] - pub(crate) fn wait_timeout(&mut self, timeout: Duration) -> io::Result> { + /// + /// On Unix a successful wait reaps the child; call [`process`](Self::process) before it to + /// retain a verified process handle. Later waits return the same status. + pub fn wait_timeout(&mut self, timeout: Duration) -> io::Result> { #[cfg(windows)] return Ok(self.process.wait_timeout(timeout)?.map(i64::from)); #[cfg(not(windows))] @@ -286,7 +288,7 @@ impl Launched { } /// Reaps the child if it has exited, and returns its status if it has. - #[cfg(all(feature = "cli", not(windows)))] + #[cfg(not(windows))] fn try_reap(&mut self) -> io::Result> { let status = self.child.try_wait()?; self.reaped |= status.is_some(); @@ -329,11 +331,17 @@ impl Launched { } /// Returns `Popen.returncode` for `status`: the exit code, or the negated signal number. -#[cfg(all(feature = "cli", not(windows)))] +#[cfg(not(windows))] fn status_value(status: std::process::ExitStatus) -> i64 { + #[cfg(unix)] use std::os::unix::process::ExitStatusExt; - match (status.code(), status.signal()) { + #[cfg(unix)] + let signal = status.signal(); + #[cfg(not(unix))] + let signal: Option = None; + + match (status.code(), signal) { (Some(code), _) => i64::from(code), (None, Some(signal)) => -i64::from(signal), (None, None) => 0, 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/host/vm.rs b/nvx-rs/src/host/vm.rs index 09893b92b..770524c7a 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -10,15 +10,21 @@ //! which OpenVMM gives its temporary files. [`Capability`] holds the credential of a VM's control //! endpoint, which the library never writes to a file. +use std::ffi::OsString; use std::fmt; +use std::fs::{self, File, OpenOptions}; use std::io; use std::path::{Path, PathBuf}; +use crate::host::config::{ConsoleAttachment, VmConfig}; use crate::host::paths::{absolute, normalized}; +use crate::host::platform; #[cfg(not(windows))] use crate::host::platform::{check_store_dir as check_dir, create_store_dir as create_dir}; #[cfg(windows)] use crate::host::platform::{check_vm_dir as check_dir, create_vm_dir as create_dir}; +use crate::host::runtime::Runtime; +use crate::host::snapshot::Snapshot; use crate::protocol::CAPABILITY_LEN; use crate::{Error, Result}; @@ -98,10 +104,6 @@ impl VmDir { } /// Returns a launch's outputs, with a boot-console endpoint if requested. Creates nothing. - #[cfg_attr( - not(test), - expect(dead_code, reason = "SL2.8's vm::create calls it (#1099)") - )] pub(crate) fn outputs(&self, console: bool) -> Result { Ok(VmOutputs { log: self.path.join(LOG), @@ -193,6 +195,304 @@ impl fmt::Debug for Capability { } } +/// An owned descriptor on Unix. +#[cfg(unix)] +type Owned = std::os::fd::OwnedFd; +/// An owned handle on Windows. +#[cfg(windows)] +type Owned = std::os::windows::io::OwnedHandle; +/// An owned file on hosts without descriptors or handles. +#[cfg(not(any(unix, windows)))] +type Owned = File; + +/// An already opened resource that a VMM inherits, owned until dropped or transferred. +/// +/// Conversions move the same descriptor or handle; they neither duplicate nor reopen it. +/// Borrowed raw descriptors and handles cannot transfer ownership into this type. +#[derive(Debug)] +pub struct InheritedHandle { + /// The owned platform resource. + handle: Owned, +} + +impl From for InheritedHandle { + fn from(file: File) -> Self { + Self { + handle: file.into(), + } + } +} + +#[cfg(unix)] +impl From for InheritedHandle { + fn from(handle: std::os::fd::OwnedFd) -> Self { + Self { handle } + } +} + +#[cfg(unix)] +impl From for std::os::fd::OwnedFd { + fn from(resource: InheritedHandle) -> Self { + resource.handle + } +} + +#[cfg(unix)] +impl std::os::fd::AsFd for InheritedHandle { + fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> { + std::os::fd::AsFd::as_fd(&self.handle) + } +} + +#[cfg(windows)] +impl From for InheritedHandle { + fn from(handle: std::os::windows::io::OwnedHandle) -> Self { + Self { handle } + } +} + +#[cfg(windows)] +impl From for std::os::windows::io::OwnedHandle { + fn from(resource: InheritedHandle) -> Self { + resource.handle + } +} + +#[cfg(windows)] +impl std::os::windows::io::AsHandle for InheritedHandle { + fn as_handle(&self) -> std::os::windows::io::BorrowedHandle<'_> { + std::os::windows::io::AsHandle::as_handle(&self.handle) + } +} + +/// A complete cold launch handed to the caller's launcher, with all its resources owned. +/// +/// The launcher supplies the capability on stdin through a pipe closed to writers before +/// spawning, and uses the opened log for stdout and stderr. It allows only the standard streams +/// and `inherit`, under their original descriptor or handle numbers, into the child. +/// +/// Preserve the launch contract of [`crate::host::supervisor::spawn`]: detach on Linux, clear +/// only the child's owner umask bits, and keep allowed descriptors close-on-exec in the caller; +/// on Windows, use an explicit handle list, restore temporary inheritability, start without a +/// console in the log-named job, and honor `breakaway_from_job`. Windows callers coordinate their +/// other spawns with the inheritance window. If setup fails after spawning, terminate and reap +/// only that child before returning an error. The caller journals recovery data and keeps the +/// VM directory and runtime inputs stable; this value starts no process or listener. +#[derive(Debug)] +pub struct VmmCommand { + /// The checked OpenVMM executable, as the runtime supplied it. + pub program: PathBuf, + /// The complete, checked arguments, without the executable. + pub arguments: Vec, + /// The VM directory in which OpenVMM runs. + pub working_dir: PathBuf, + /// The control credential, never written to disk by the library. + pub capability: Capability, + /// The already chosen output files and endpoints. + pub outputs: VmOutputs, + /// Owned resources allowed into the child besides its standard streams. + pub inherit: Vec, + /// The same private log that preparation opened. + pub log: File, + /// Whether Windows leaves the caller's job, which must permit breakaway. + pub breakaway_from_job: bool, +} + +/// A checked cold launch, with its capability, chosen outputs, and already opened log. +/// +/// Creation starts no process and binds no listener. The caller keeps the VM directory and +/// runtime inputs stable, and may journal the capability and outputs before a later spawn. +/// Dropping a successful preparation closes its files but leaves its output paths to the caller. +#[derive(Debug)] +pub struct Prepared { + /// The control credential, never written to disk. + capability: Capability, + /// The launch's fixed file paths and chosen endpoints. + outputs: VmOutputs, + /// The newly created log, kept open rather than reopened by name. + log: File, + /// The complete arguments, checked against the actual runtime and output paths. + arguments: Vec, + /// Already opened resources added by the caller, retained until the preparation is dropped. + inherit: Vec, + /// The executable supplied by the checked runtime. + program: PathBuf, + /// The checked VM working directory. + working_dir: PathBuf, + /// The runtime's Windows job-breakaway setting. + breakaway_from_job: bool, +} + +impl Prepared { + /// Returns the credential to journal before a spawn or use when attaching a session. + pub fn capability(&self) -> &Capability { + &self.capability + } + + /// Returns the launch's log, outcome, control, and optional boot-console names. + pub fn outputs(&self) -> &VmOutputs { + &self.outputs + } + + /// Adds an already opened resource to the inheritance allowlist, transferring ownership. + /// + /// The resource is not reopened, and remains open for the lifetime of this preparation. + pub fn inherit(&mut self, resource: impl Into) { + self.inherit.push(resource.into()); + } + + /// Returns the same opened log that creation made, for example to take a liveness claim. + pub fn log(&self) -> &File { + &self.log + } + + /// Returns the complete, checked OpenVMM arguments without starting a process. + pub fn arguments(&self) -> &[OsString] { + &self.arguments + } + + /// Transfers the complete checked launch to the caller's launcher. + /// + /// Moves the same opened log and inherited resources, without cloning, reopening, rendering + /// arguments again or rediscovering paths. Starts no process or listener. The caller follows + /// [`VmmCommand`]'s launch contract and may retain its capability and outputs for recovery. + pub fn into_command(self) -> VmmCommand { + VmmCommand { + program: self.program, + arguments: self.arguments, + working_dir: self.working_dir, + capability: self.capability, + outputs: self.outputs, + inherit: self.inherit, + log: self.log, + breakaway_from_job: self.breakaway_from_job, + } + } +} + +/// Creates an inspectable cold launch in `dir`, without starting a process or binding endpoints. +/// +/// A cold launch needs [`VmConfig::boot`]. Restoring `from` a [`Snapshot`] is not supported yet. +/// The caller keeps the directory and runtime inputs stable during creation and a later spawn. +/// Runtime and configuration paths are used as supplied; output paths come from `dir`. +/// +/// The log is created anew, privately, and kept open in [`Prepared`]. Other outputs are reserved +/// names that OpenVMM creates when it runs: existing entries at those names are refused, not +/// replaced. A failure closes and removes only the log that this invocation created, preserving +/// every pre-existing entry and the caller's inputs. The capability is never written to disk. +/// +/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a restore, a missing +/// boot, a refused configuration, unrepresentable Linux socket names, or a complete Windows +/// command line beyond the portable limit. +/// The final check includes the actual runtime and output paths, with no reserve, on every host. +/// Filesystem failures have kind [`ErrorKind::Io`](crate::ErrorKind::Io), retaining their source; +/// a rollback failure is reported along with the original error. +pub fn create( + runtime: &Runtime, + config: &VmConfig, + from: Option<&Snapshot>, + dir: &VmDir, +) -> Result { + if from.is_some() { + return Err(Error::unsupported( + "creating a VM from a Snapshot is not supported yet", + )); + } + if config.boot.is_none() { + return Err(Error::unsupported("creating a cold VM needs Boot")); + } + VmDir::open(dir.path())?; + platform::check_working_dir(dir.path())?; + let outputs = dir.outputs(config.console == ConsoleAttachment::Listener)?; + if !cfg!(windows) { + for endpoint in std::iter::once(&outputs.endpoint).chain(outputs.console.as_ref()) { + if endpoint.len() >= 108 || endpoint.contains([',', '=']) { + return Err(Error::unsupported(format!( + "the VM socket {endpoint} must be shorter than 108 bytes and hold no ',' or '='", + ))); + } + } + } + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let log = options.open(&outputs.log).map_err(|error| { + Error::io(format!( + "cannot create the VM log {}", + outputs.log.display() + )) + .with_source(error) + })?; + let checked = (|| { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + log.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|error| { + Error::io("cannot make the new VM log private").with_source(error) + })?; + } + if let Some(problem) = platform::private_file_problem(&log) + .map_err(|error| Error::io("cannot check the new VM log").with_source(error))? + { + return Err(Error::io(format!("the new VM log {problem}"))); + } + unused_output(&outputs.outcome)?; + if !cfg!(windows) { + unused_output(Path::new(&outputs.endpoint))?; + if let Some(console) = &outputs.console { + unused_output(Path::new(console))?; + } + } + let capability = Capability(crate::host::instance::generate_capability()?); + let arguments = config.openvmm_arguments( + runtime, + &outputs.endpoint, + &outputs.outcome, + outputs.console.as_deref(), + )?; + Ok((capability, arguments)) + })(); + match checked { + Ok((capability, arguments)) => Ok(Prepared { + capability, + outputs, + log, + arguments, + inherit: Vec::new(), + program: runtime.openvmm.clone(), + working_dir: dir.path.clone(), + breakaway_from_job: runtime.windows_breakaway, + }), + Err(error) => { + drop(log); + match fs::remove_file(&outputs.log) { + Ok(()) => Err(error), + Err(cleanup) if cleanup.kind() == io::ErrorKind::NotFound => Err(error), + Err(cleanup) => Err(Error::new( + error.kind(), + format!( + "{error}; cannot remove the created VM log {}: {cleanup}", + outputs.log.display() + ), + ) + .with_source(error)), + } + } + } +} + +/// Refuses an existing output entry without opening, replacing, or removing it. +fn unused_output(path: &Path) -> Result<()> { + let error = match fs::symlink_metadata(path) { + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => error, + Ok(_) => io::Error::new(io::ErrorKind::AlreadyExists, "the output already exists"), + }; + Err(Error::io(format!("cannot reserve VM output {}", path.display())).with_source(error)) +} + /// Retains the host's I/O failure, or classifies an unsupported host as unavailable. fn io_error(action: &str, path: &Path, error: io::Error) -> Error { let message = format!("cannot {action} the VM directory {}", path.display()); diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index c8ed0d350..e333f029f 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -2,6 +2,12 @@ use std::fs; use super::*; use crate::ErrorKind; +use crate::activation::{MachineIdMode, View}; +use crate::host::config::{ + Boot, PROCESS_COMMAND_LINE_LIMIT, RootConfig, process_command_line_units, +}; +use crate::host::runtime::{ArtifactIdentity, LaunchOptions, RuntimeIdentity}; +use crate::hypervisor::Hypervisor; fn is_fresh_pipe(endpoint: &str, prefix: &str) -> bool { endpoint @@ -399,3 +405,349 @@ fn creating_and_opening_a_vm_directory_writes_no_capability_file_or_output() { .collect(); assert_eq!(entries, ["vm"]); } + +fn cold_config() -> VmConfig { + VmConfig { + processors: 1, + memory_mib: 256, + root: RootConfig::Blockless, + view: View { + hostname: b"box".to_vec(), + machine_id: MachineIdMode::Image, + }, + network: None, + filesystem: None, + console: ConsoleAttachment::Listener, + boot: Some(Boot { + command_line: "nvx_entrypoint=/bin/sh nvx_arg=-c nvx_arg=exit".to_owned(), + }), + } +} + +fn cold_runtime() -> Runtime { + let identity = ArtifactIdentity::new("test-runtime").unwrap(); + Runtime { + openvmm: PathBuf::from("missing-openvmm"), + kernel: PathBuf::from("missing-kernel"), + initramfs: PathBuf::from("missing-initramfs"), + identity: RuntimeIdentity::new(identity.clone(), identity.clone(), identity), + hypervisor: if cfg!(windows) { + Hypervisor::Whp + } else { + Hypervisor::Mshv + }, + cpu_profile: None, + windows_breakaway: false, + options: LaunchOptions::default(), + } +} + +#[test] +fn cold_creation_is_inspectable_without_a_process_listener_or_capability_file() { + for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { + let (_directory, vm) = vm_dir(); + let runtime = cold_runtime(); + let config = VmConfig { + console, + ..cold_config() + }; + let prepared = create(&runtime, &config, None, &vm).unwrap(); + let outputs = prepared.outputs(); + assert_eq!(outputs.log, vm.path().join("openvmm.log")); + assert_eq!( + outputs.console.is_some(), + console == ConsoleAttachment::Listener + ); + assert!(!outputs.outcome.exists()); + assert_eq!(prepared.capability().as_bytes().len(), CAPABILITY_LEN); + assert_ne!(prepared.capability().as_bytes(), &[0; CAPABILITY_LEN]); + assert_eq!( + prepared.arguments(), + config + .openvmm_arguments( + &runtime, + &outputs.endpoint, + &outputs.outcome, + outputs.console.as_deref() + ) + .unwrap() + ); + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 1); + drop(prepared); + assert!(vm.path().join(LOG).exists()); + } +} + +#[test] +fn a_missing_boot_and_a_snapshot_are_refused_before_any_output_is_created() { + let (_directory, vm) = vm_dir(); + let runtime = cold_runtime(); + let mut config = cold_config(); + config.boot = None; + let snapshot = Snapshot { _private: () }; + for (from, config, expected) in [ + (None, &config, "Boot"), + (Some(&snapshot), &cold_config(), "Snapshot"), + ] { + let error = create(&runtime, config, from, &vm).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert!(error.to_string().contains(expected), "{error}"); + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 0); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn cold_creation_refuses_listener_delimiters_before_creating_outputs() { + for delimiter in [',', '='] { + for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(format!("vm{delimiter}option")); + create_private_dir(&path).unwrap(); + let vm = VmDir::open(&path).unwrap(); + let input = path.join("caller-input"); + fs::write(&input, b"borrowed input").unwrap(); + let config = VmConfig { + console, + ..cold_config() + }; + let error = create(&cold_runtime(), &config, None, &vm).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert_eq!(fs::read(&input).unwrap(), b"borrowed input"); + assert_eq!(fs::read_dir(&path).unwrap().count(), 1); + } + } +} + +#[cfg(target_os = "linux")] +#[test] +fn cold_creation_checks_socket_path_byte_limits_before_creating_outputs() { + use std::os::unix::net::UnixListener; + + for socket_bytes in [107usize, 108, 109] { + for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { + let directory = tempfile::tempdir().unwrap(); + let name_bytes = socket_bytes + .checked_sub(directory.path().as_os_str().len() + CONTROL.len() + 2) + .expect("a short temporary directory is needed for the socket boundary"); + let prefix = "unicode space "; + let remaining = name_bytes.checked_sub(prefix.len()).unwrap(); + let mut name = format!("{prefix}{}", "\u{e9}".repeat(remaining / 2)); + name.push_str(&"x".repeat(remaining % 2)); + let path = directory.path().join(name); + create_private_dir(&path).unwrap(); + let vm = VmDir::open(&path).unwrap(); + let outputs = vm.outputs(console == ConsoleAttachment::Listener).unwrap(); + assert_eq!(outputs.endpoint.len(), socket_bytes); + assert!(outputs.endpoint.encode_utf16().count() < socket_bytes); + let input = path.join("caller-input"); + fs::write(&input, b"borrowed input").unwrap(); + let config = VmConfig { + console, + ..cold_config() + }; + let result = create(&cold_runtime(), &config, None, &vm); + if socket_bytes == 107 { + let prepared = result.unwrap(); + for endpoint in std::iter::once(&prepared.outputs().endpoint) + .chain(prepared.outputs().console.as_ref()) + { + let listener = UnixListener::bind(endpoint).unwrap(); + assert_eq!( + listener.local_addr().unwrap().as_pathname(), + Some(Path::new(endpoint)) + ); + } + } else { + let error = result.unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert_eq!(fs::read_dir(&path).unwrap().count(), 1); + } + assert_eq!(fs::read(&input).unwrap(), b"borrowed input"); + } + } +} + +#[test] +fn creation_rolls_back_its_log_and_never_removes_preexisting_outputs_or_inputs() { + let mut names = vec![LOG, OUTCOME]; + if !cfg!(windows) { + names.extend([CONTROL, CONSOLE]); + } + for name in names { + let (_directory, vm) = vm_dir(); + let existing = vm.path().join(name); + let input = vm.path().join("caller-input"); + fs::write(&existing, b"preexisting output").unwrap(); + fs::write(&input, b"borrowed input").unwrap(); + let error = create(&cold_runtime(), &cold_config(), None, &vm).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io, "{error}"); + assert_eq!(fs::read(&existing).unwrap(), b"preexisting output"); + assert_eq!(fs::read(&input).unwrap(), b"borrowed input"); + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 2); + } +} + +#[test] +fn the_log_and_inherited_resources_stay_open_without_reopening_their_paths() { + use std::io::Write; + let (_directory, vm) = vm_dir(); + let mut prepared = create(&cold_runtime(), &cold_config(), None, &vm).unwrap(); + let log = prepared.outputs().log.clone(); + let moved_log = vm.path().join("original-log"); + fs::rename(&log, &moved_log).unwrap(); + fs::write(&log, b"replacement log").unwrap(); + prepared + .log() + .try_clone() + .unwrap() + .write_all(b"original log") + .unwrap(); + assert_eq!(fs::read(&moved_log).unwrap(), b"original log"); + assert_eq!(fs::read(&log).unwrap(), b"replacement log"); + let input = vm.path().join("caller-input"); + let moved_input = vm.path().join("original-input"); + let file = File::create(&input).unwrap(); + prepared.inherit(file); + fs::rename(&input, &moved_input).unwrap(); + fs::write(&input, b"replacement input").unwrap(); + inherited_file(prepared.inherit.pop().unwrap()) + .write_all(b"original input") + .unwrap(); + assert_eq!(fs::read(&moved_input).unwrap(), b"original input"); + assert_eq!(fs::read(&input).unwrap(), b"replacement input"); +} + +fn inherited_file(resource: InheritedHandle) -> File { + #[cfg(any(unix, windows))] + return File::from(Owned::from(resource)); + #[cfg(not(any(unix, windows)))] + resource.handle +} + +#[test] +fn a_command_moves_the_checked_launch_and_the_same_opened_resources() { + use std::io::Write; + + let (_directory, vm) = vm_dir(); + let mut runtime = cold_runtime(); + runtime.windows_breakaway = true; + let mut prepared = create(&runtime, &cold_config(), None, &vm).unwrap(); + let program = runtime.openvmm.clone(); + let arguments = prepared.arguments().as_ptr(); + let capability = *prepared.capability().as_bytes(); + let outputs = prepared.outputs().clone(); + let original_log = vm.path().join("original-log"); + fs::rename(&outputs.log, &original_log).unwrap(); + fs::write(&outputs.log, b"replacement log").unwrap(); + let input = vm.path().join("input"); + let original_input = vm.path().join("original-input"); + prepared.inherit(File::create(&input).unwrap()); + let resources = prepared.inherit.as_ptr(); + fs::rename(&input, &original_input).unwrap(); + fs::write(&input, b"replacement input").unwrap(); + #[cfg(unix)] + let log_handle = std::os::fd::AsRawFd::as_raw_fd(prepared.log()); + #[cfg(windows)] + let log_handle = std::os::windows::io::AsRawHandle::as_raw_handle(prepared.log()); + runtime.openvmm = "different-program".into(); + runtime.windows_breakaway = false; + + let mut command = prepared.into_command(); + assert_eq!(command.program, program); + assert_eq!(command.working_dir, vm.path()); + assert!(command.breakaway_from_job); + assert_eq!(command.arguments.as_ptr(), arguments); + assert_eq!(command.inherit.as_ptr(), resources); + assert_eq!(command.capability.as_bytes(), &capability); + assert_eq!(command.outputs, outputs); + #[cfg(unix)] + assert_eq!(std::os::fd::AsRawFd::as_raw_fd(&command.log), log_handle); + #[cfg(windows)] + assert_eq!( + std::os::windows::io::AsRawHandle::as_raw_handle(&command.log), + log_handle + ); + command.log.write_all(b"original log").unwrap(); + inherited_file(command.inherit.pop().unwrap()) + .write_all(b"original input") + .unwrap(); + assert_eq!(fs::read(original_log).unwrap(), b"original log"); + assert_eq!(fs::read(original_input).unwrap(), b"original input"); + assert_eq!(fs::read(&outputs.log).unwrap(), b"replacement log"); + assert_eq!(fs::read(input).unwrap(), b"replacement input"); + drop(command); + assert!(outputs.log.exists()); + assert!(!outputs.outcome.exists()); +} + +#[cfg(unix)] +#[test] +fn an_owned_descriptor_moves_into_and_out_of_an_inherited_handle() { + use std::io::Write; + use std::os::fd::{AsFd, AsRawFd, OwnedFd}; + + let file = tempfile::tempfile().unwrap(); + let number = file.as_raw_fd(); + let descriptor: OwnedFd = file.into(); + let resource: InheritedHandle = descriptor.into(); + assert_eq!(resource.as_fd().as_raw_fd(), number); + let descriptor: OwnedFd = resource.into(); + assert_eq!(descriptor.as_raw_fd(), number); + File::from(descriptor).write_all(b"still open").unwrap(); +} + +#[cfg(windows)] +#[test] +fn an_owned_windows_handle_moves_into_and_out_of_an_inherited_handle() { + use std::io::Write; + use std::os::windows::io::{AsHandle, AsRawHandle, OwnedHandle}; + + let file = tempfile::tempfile().unwrap(); + let number = file.as_raw_handle(); + let handle: OwnedHandle = file.into(); + let resource: InheritedHandle = handle.into(); + assert_eq!(resource.as_handle().as_raw_handle(), number); + let handle: OwnedHandle = resource.into(); + assert_eq!(handle.as_raw_handle(), number); + File::from(handle).write_all(b"still open").unwrap(); +} + +#[test] +fn creation_checks_the_complete_actual_command_line_at_the_windows_boundary() { + let (_directory, vm) = vm_dir(); + let config = cold_config(); + config.check_command_line().unwrap(); + let mut runtime = cold_runtime(); + let outputs = vm.outputs(true).unwrap(); + let arguments = config + .openvmm_arguments( + &runtime, + &outputs.endpoint, + &outputs.outcome, + outputs.console.as_deref(), + ) + .unwrap(); + let length = process_command_line_units(runtime.openvmm.as_os_str(), &arguments); + runtime + .openvmm + .as_mut_os_string() + .push("x".repeat(PROCESS_COMMAND_LINE_LIMIT - length)); + let prepared = create(&runtime, &config, None, &vm).unwrap(); + assert_eq!( + process_command_line_units(runtime.openvmm.as_os_str(), prepared.arguments()), + PROCESS_COMMAND_LINE_LIMIT + ); + let log = prepared.outputs().log.clone(); + drop(prepared); + fs::remove_file(&log).unwrap(); + let input = vm.path().join("caller-input"); + fs::write(&input, b"borrowed input").unwrap(); + runtime.openvmm.as_mut_os_string().push("x"); + let error = create(&runtime, &config, None, &vm).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert!(error.to_string().contains("32768"), "{error}"); + assert!(!log.exists()); + assert_eq!(fs::read(&input).unwrap(), b"borrowed input"); + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 1); +} diff --git a/nvx-rs/src/protocol/mod.rs b/nvx-rs/src/protocol/mod.rs index e38062744..ce2f3c317 100644 --- a/nvx-rs/src/protocol/mod.rs +++ b/nvx-rs/src/protocol/mod.rs @@ -21,6 +21,7 @@ //! server, which OpenVMM's `virtnet_dns=` token names, with the resolver file that names it. //! - [`root`]: the boot tokens of a layered root, which name its image's format and identity and //! its upper, with the host's renderer and the guest's parser. +//! - [`view`]: a cold boot's hostname bytes and machine-ID mode, with their renderer and parser. //! - [`export`]: the boot tokens of the host export, the aggregate virtio-fs device from which a //! guest maps host paths, and the guest's strict parser of them. //! - [`share`]: the guest targets that a sandbox's live shares may not take, which the host and @@ -44,7 +45,7 @@ pub mod cmdline; mod exec; pub mod export; mod features; -mod maps; +pub mod maps; pub mod nvxs; mod outer; pub mod root; @@ -52,6 +53,7 @@ pub mod share; pub mod status; #[cfg(test)] mod tests; +pub mod view; pub use app::{ APP_CANCEL, APP_ERROR, APP_EXEC, APP_EXIT, APP_FEATURES, APP_HEADER_LEN, APP_MAGIC, APP_MAPS, 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/src/protocol/view.rs b/nvx-rs/src/protocol/view.rs new file mode 100644 index 000000000..79b618e21 --- /dev/null +++ b/nvx-rs/src/protocol/view.rs @@ -0,0 +1,129 @@ +//! A cold boot's view: `nvx_hostname=HEX`, the hostname's bytes as lowercase hexadecimal, two +//! digits per byte, and `nvx_machine_id=image` or `nvx_machine_id=generated`. +//! +//! The hostname holds 1 to [`MAX_HOSTNAME_BYTES`] bytes without a NUL, as [`View`] specifies, +//! without a UTF-8 or DNS restriction. It is always encoded, even for an ASCII hostname: +//! `box` is `nvx_hostname=626f78`, not the legacy sandbox's raw `nvx_hostname=box`. +//! Consumers of this codec use the decoded bytes, not the legacy raw-hostname reader. +//! Restores receive their view through an activation record instead. + +use super::cmdline::{self, Token}; +use super::{ProtocolError, violation}; +use crate::activation::{self, MAX_HOSTNAME_BYTES, MachineIdMode, View}; + +/// Prefix of the token that encodes the hostname's bytes as lowercase hexadecimal. +pub const HOSTNAME_TOKEN: &str = "nvx_hostname="; +/// Prefix of the token that names the machine-ID mode, `image` or `generated`. +pub const MACHINE_ID_TOKEN: &str = "nvx_machine_id="; + +/// Renders the hostname token, then the machine-ID token. +/// +/// Fails with a [`ProtocolError`] if the hostname is not 1 to [`MAX_HOSTNAME_BYTES`] non-NUL +/// bytes. Its hexadecimal value needs no quoting or escaping on the kernel command line. +pub fn tokens(view: &View) -> Result<[String; 2], ProtocolError> { + if !activation::is_hostname(&view.hostname) { + return Err(violation(format!( + "the view's hostname must be 1 to {MAX_HOSTNAME_BYTES} bytes without a NUL" + ))); + } + let hostname: String = view + .hostname + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let machine_id = match view.machine_id { + MachineIdMode::Image => "image", + MachineIdMode::Generated => "generated", + }; + Ok([ + format!("{HOSTNAME_TOKEN}{hostname}"), + format!("{MACHINE_ID_TOKEN}{machine_id}"), + ]) +} + +/// Reads a complete view, or `None` when neither token is present. +/// +/// Reads exact token names with [`cmdline::init_tokens`]: spaces, tabs and newlines separate +/// tokens, quotes are literal, and `--` does not stop the reader. Both tokens must occur +/// exactly once, in either order. Missing values, partial pairs, duplicates and values outside +/// the forms of the [module](self) fail with a [`ProtocolError`]; unrelated tokens are ignored. +pub fn parse(command_line: &[u8]) -> Result, ProtocolError> { + let mut hostname = None; + let mut machine_id = None; + for Token { name, value } in cmdline::init_tokens(command_line) { + let parameter = match name { + b"nvx_hostname" => "nvx_hostname", + b"nvx_machine_id" => "nvx_machine_id", + _ => continue, + }; + let value = value + .ok_or_else(|| violation(format!("the {parameter} kernel parameter has no value")))?; + let repeated = if parameter == "nvx_hostname" { + hostname.is_some() + } else { + machine_id.is_some() + }; + if repeated { + return Err(violation(format!( + "the {parameter} kernel parameter is repeated" + ))); + } + if parameter == "nvx_hostname" { + hostname = Some(parse_hostname(value)?); + } else { + machine_id = Some(match value { + b"image" => MachineIdMode::Image, + b"generated" => MachineIdMode::Generated, + _ => { + return Err(violation( + "the nvx_machine_id kernel parameter must be image or generated", + )); + } + }); + } + } + match (hostname, machine_id) { + (Some(hostname), Some(machine_id)) => Ok(Some(View { + hostname, + machine_id, + })), + (None, None) => Ok(None), + _ => Err(violation( + "a view needs both nvx_hostname and nvx_machine_id kernel parameters", + )), + } +} + +/// Decodes a bounded, canonical hostname value, refusing a decoded NUL. +fn parse_hostname(value: &[u8]) -> Result, ProtocolError> { + if value.is_empty() + || value.len() > 2 * MAX_HOSTNAME_BYTES + || !value.len().is_multiple_of(2) + || !value + .iter() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return Err(violation(format!( + "the nvx_hostname kernel parameter must encode 1 to {MAX_HOSTNAME_BYTES} bytes \ + as lowercase hexadecimal" + ))); + } + let digit = |byte: u8| { + if byte.is_ascii_digit() { + byte - b'0' + } else { + byte - b'a' + 10 + } + }; + let hostname: Vec = value + .chunks_exact(2) + .map(|pair| digit(pair[0]) * 16 + digit(pair[1])) + .collect(); + if !activation::is_hostname(&hostname) { + return Err(violation("the nvx_hostname kernel parameter encodes a NUL")); + } + Ok(hostname) +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/protocol/view/tests.rs b/nvx-rs/src/protocol/view/tests.rs new file mode 100644 index 000000000..3311ca664 --- /dev/null +++ b/nvx-rs/src/protocol/view/tests.rs @@ -0,0 +1,124 @@ +use super::*; + +#[test] +fn view_tokens_preserve_hostname_bytes_and_both_machine_id_modes() { + for hostname in [ + b"x".to_vec(), + b"box".to_vec(), + b" \t\n\"=\\\x80\xff".to_vec(), + vec![0xff; MAX_HOSTNAME_BYTES], + ] { + for machine_id in [MachineIdMode::Image, MachineIdMode::Generated] { + let view = View { + hostname: hostname.clone(), + machine_id, + }; + let rendered = tokens(&view).unwrap(); + assert_eq!(parse(rendered.join(" ").as_bytes()).unwrap(), Some(view)); + } + } + assert_eq!( + tokens(&View { + hostname: b"box".to_vec(), + machine_id: MachineIdMode::Generated, + }) + .unwrap(), + ["nvx_hostname=626f78", "nvx_machine_id=generated"] + ); +} + +#[test] +fn a_view_is_absent_only_when_neither_exact_init_token_is_present() { + for unrelated in [ + "", + "quiet nvx-hostname=box nvx-machine-id=unknown", + "\"nvx_hostname=626f78\" other=nvx_machine_id=image", + ] { + assert_eq!(parse(unrelated.as_bytes()).unwrap(), None); + } + assert_eq!( + parse(b"quiet\t-- nvx_machine_id=image\nnvx_hostname=626f78").unwrap(), + Some(View { + hostname: b"box".to_vec(), + machine_id: MachineIdMode::Image, + }) + ); +} + +#[test] +fn hostname_values_must_be_bounded_canonical_hexadecimal_without_a_nul() { + let mut malformed: Vec> = [ + b"".as_slice(), + b"0", + b"gg", + b"6A", + b"0x62", + b"box", + b"62006f", + b"62=6f", + b"62\r", + b"62\0", + b"\"62\"", + b"\xff\xff", + ] + .into_iter() + .map(<[u8]>::to_vec) + .collect(); + malformed.push(b"62".repeat(MAX_HOSTNAME_BYTES + 1)); + for value in malformed { + let line = [ + b"nvx_hostname=".as_slice(), + &value, + b" nvx_machine_id=image", + ] + .concat(); + let error = parse(&line).unwrap_err(); + assert!(error.to_string().contains("nvx_hostname"), "{error}"); + } + for hostname in [ + Vec::new(), + vec![b'a'; MAX_HOSTNAME_BYTES + 1], + b"a\0b".to_vec(), + ] { + assert!( + tokens(&View { + hostname, + machine_id: MachineIdMode::Image, + }) + .is_err() + ); + } +} + +#[test] +fn machine_id_values_are_exact_and_unquoted() { + for value in [ + b"".as_slice(), + b"Image", + b"generate", + b"image=generated", + b"image\r", + b"\"image\"", + b"\xff", + ] { + let line = [b"nvx_hostname=626f78 nvx_machine_id=".as_slice(), value].concat(); + let error = parse(&line).unwrap_err(); + assert!(error.to_string().contains("nvx_machine_id"), "{error}"); + } +} + +#[test] +fn partial_missing_and_duplicate_owned_tokens_are_refused() { + for line in [ + "nvx_hostname=626f78", + "nvx_machine_id=image", + "nvx_hostname nvx_machine_id=image", + "nvx_hostname=626f78 nvx_machine_id", + "nvx_hostname=626f78 nvx_hostname=626f78 nvx_machine_id=image", + "nvx_hostname=626f78 nvx_hostname=61 nvx_machine_id=image", + "nvx_hostname=626f78 nvx_machine_id=image nvx_machine_id=image", + "nvx_hostname=626f78 nvx_machine_id=image nvx_machine_id=generated", + ] { + assert!(parse(line.as_bytes()).is_err(), "{line}"); + } +} diff --git a/nvx-rs/tests/checked_handoff.rs b/nvx-rs/tests/checked_handoff.rs index b07a9e340..0875857f2 100644 --- a/nvx-rs/tests/checked_handoff.rs +++ b/nvx-rs/tests/checked_handoff.rs @@ -179,6 +179,55 @@ fn fixture_arguments(name: &str) -> Vec { .to_vec() } +/// A VMM fixture that exits with the requested status once the caller lets it go. +#[test] +#[ignore = "fixture that the exit-status test runs as a VMM"] +fn exit_status_fixture() { + let directory = env::current_dir().unwrap(); + if !directory.join(VMM_MARKER).exists() { + return; + } + let mut capability = [0; CAPABILITY_LEN]; + io::stdin().read_exact(&mut capability).unwrap(); + assert_eq!(capability, CAPABILITY); + let deadline = Instant::now() + VMM_LIFETIME; + while !directory.join(GO_MARKER).exists() { + if directory.join(EXIT_MARKER).exists() || Instant::now() >= deadline { + return; + } + thread::sleep(Duration::from_millis(5)); + } + let status = fs::read_to_string(directory.join("exit-status")).unwrap(); + std::process::exit(status.parse().unwrap()); +} + +#[test] +fn launched_waits_return_and_retain_the_actual_exit_status() { + for status in [0, 17] { + let mut vmm = VmmDirectory::new(); + fs::write(vmm.path().join("exit-status"), status.to_string()).unwrap(); + let command = VmmCommand::new( + env::current_exe().unwrap(), + fixture_arguments("exit_status_fixture"), + vmm.path(), + ); + let log = File::create(vmm.path().join("vmm.log")).unwrap(); + vmm.launched = Some(supervisor::spawn(&command, &CAPABILITY, log).unwrap()); + assert_eq!( + vmm.launched + .as_mut() + .unwrap() + .wait_timeout(Duration::ZERO) + .unwrap(), + None + ); + File::create(vmm.path().join(GO_MARKER)).unwrap(); + let launched = vmm.launched.as_mut().unwrap(); + assert_eq!(launched.wait_timeout(TIMEOUT).unwrap(), Some(status)); + assert_eq!(launched.wait_timeout(Duration::ZERO).unwrap(), Some(status)); + } +} + /// Returns the arguments that pass a VMM fixture the `--microvm-sandbox-block` values of /// `prepared`. fn block_arguments(prepared: &PreparedLaunch) -> Vec { diff --git a/nvx-rs/tests/fixtures/root-tokens.json b/nvx-rs/tests/fixtures/root-tokens.json index 1f3135d48..e7314963b 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"}, + {"name": "a cap before a RAM upper without an image", "command_line": "nvx_ram_upper_percent=50 nvx_overlay_upper=ramfs", "error": "the nvx_ram_upper_percent kernel parameter needs an image, which nvx_layer names"} ] } diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index 9577c9ff4..a6b816bfe 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -16,6 +16,6 @@ "nvx-rs/src/host/network/address.rs": 2, "nvx-rs/src/host/platform/windows/spawn.rs": 1, "nvx-rs/src/host/sandbox/plan.rs": 5, - "nvx-rs/src/host/vm.rs": 7 + "nvx-rs/src/host/vm.rs": 6 } } diff --git a/scripts/test_adversarial.py b/scripts/test_adversarial.py index 1b3cb3a2c..674fe88ea 100644 --- a/scripts/test_adversarial.py +++ b/scripts/test_adversarial.py @@ -76,7 +76,11 @@ run_bounded_process, terminate_process_tree, ) -from nvx_tools.benchmark import InteractiveProcess, run_guest_script +from nvx_tools.benchmark import ( + InteractiveProcess, + _wait_for_windows_process, + run_guest_script, +) from nvx_tools.build_constants import ( AlpineBuildConstants, InitramfsBuildConstants, @@ -853,6 +857,7 @@ def spawn_unrelated( def test_contained_guest_runner_timeout_kills_descendants(self) -> None: with tempfile.TemporaryDirectory() as temporary: child_pid_path = Path(temporary) / "child.pid" + ready_path = Path(temporary) / "ready" command = [ sys.executable, "-u", @@ -860,13 +865,61 @@ def test_contained_guest_runner_timeout_kills_descendants(self) -> None: ( "import pathlib,subprocess,sys,time; " "child=subprocess.Popen([sys.executable,'-c'," - "'import time; time.sleep(60)']); " - "pathlib.Path(sys.argv[1]).write_text(str(child.pid)); " - "print('ALPINE-MICROVM-BOOT-OK',flush=True); time.sleep(60)" + "'import os,pathlib,sys,time; " + "path=pathlib.Path(sys.argv[1]); " + 'path.with_suffix(".tmp").write_text(str(os.getpid())); ' + 'path.with_suffix(".tmp").replace(path); ' + "time.sleep(60)',sys.argv[1]]); " + "print('ALPINE-MICROVM-BOOT-OK',flush=True); " + "pathlib.Path(sys.argv[2]).touch(); time.sleep(60)" ), str(child_pid_path), + str(ready_path), ] - with self.assertRaisesRegex(RuntimeError, "did not finish"): + + def start_ready( + command: Sequence[str], + environment: dict[str, str], + *, + contain_process_tree: bool, + separate_stderr: bool, + ) -> InteractiveProcess: + interaction = InteractiveProcess( + command, + environment, + contain_process_tree=contain_process_tree, + separate_stderr=separate_stderr, + ) + ready = False + try: + deadline = time.monotonic() + 5.0 + while not ready_path.exists() or not child_pid_path.exists(): + self.assertIsNone( + interaction.process.poll(), + "fixture exited before readiness", + ) + self.assertLess( + time.monotonic(), deadline, "fixture did not become ready" + ) + time.sleep(0.01) + child_pid = int(child_pid_path.read_text(encoding="utf-8")) + self.assertGreater(child_pid, 0) + self.assertTrue(_process_running(child_pid)) + ready = True + return interaction + finally: + if not ready: + interaction.close() + + with ( + patch( + "nvx_tools.benchmark.InteractiveProcess", side_effect=start_ready + ), + self.assertRaisesRegex( + (TimeoutError, RuntimeError), + r"^guest workload did not finish within 0\.5s(?:\n|$)", + ), + ): run_guest_script( command, "exit\n", @@ -3209,6 +3262,12 @@ def test_close_is_bounded_when_a_surviving_child_holds_executor_stdout(self): check=False, capture_output=True, ) + self.assertTrue( + _wait_for_windows_process( + int(pid), max(0.0, deadline - time.monotonic()) + ), + f"surviving child {pid} did not exit after taskkill", + ) else: os.kill(int(pid), signal.SIGKILL) 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.